From 211a82fc32fc0e853473a74fe88708408ef84a6e Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Thu, 2 Jul 2026 14:03:22 +0200 Subject: [PATCH 01/10] Expose the full dashboard surface via the public API and a hosted MCP server Add REST endpoints for organization (get/update/members), API keys (list/create/delete), webhooks CRUD + regenerate-secret, RAG chat with playground parity (normal/agentic/deepResearch, JSON or SSE), and custom domain management. Add a hosted streamable-HTTP MCP server at /api/mcp (mcp-handler) with 37 tools mirroring the v1 surface, authenticated with org API keys. Unify tRPC/REST/MCP onto one service layer, fixing parity divergences: REST namespace create dropped embedding/vector-store configs, re-ingest skipped its webhook event, deleted namespaces served from cache, invalid 204-with-body responses. Security: fix api-key delete IDOR, disable better-auth stock org deletion (bypassed cleanup), honor x-tenant-id in document get/delete/download-urls. Route protectedProcedure through the AgentsetApiError conversion so dashboard mutations surface typed errors instead of 500s. --- .opencode/skills/baseline-ui | 1 - .opencode/skills/fixing-accessibility | 1 - .opencode/skills/fixing-metadata | 1 - .opencode/skills/react-email | 1 - .opencode/skills/trigger-dev-tasks | 1 - .opencode/skills/vercel-react-best-practices | 1 - apps/web/package.json | 2 + .../src/app/api/(internal-api)/chat/route.ts | 207 +------ .../src/app/api/(internal-api)/chat/schema.ts | 23 +- .../app/api/(public-api)/[transport]/route.ts | 31 + .../(public-api)/v1/api-keys/[keyId]/route.ts | 21 + .../app/api/(public-api)/v1/api-keys/route.ts | 54 ++ .../v1/namespace/[namespaceId]/chat/route.ts | 56 ++ .../[documentId]/chunks-download-url/route.ts | 51 +- .../[documentId]/file-download-url/route.ts | 46 +- .../documents/[documentId]/route.ts | 78 +-- .../[namespaceId]/hosting/domain/route.ts | 92 +++ .../namespace/[namespaceId]/hosting/route.ts | 18 +- .../ingest-jobs/[jobId]/re-ingest/route.ts | 67 +-- .../ingest-jobs/[jobId]/route.ts | 30 +- .../[namespaceId]/ingest-jobs/route.ts | 80 +-- .../v1/namespace/[namespaceId]/route.ts | 62 +- .../namespace/[namespaceId]/search/route.ts | 98 +--- .../api/(public-api)/v1/namespace/route.ts | 72 +-- .../v1/organization/members/route.ts | 26 + .../api/(public-api)/v1/organization/route.ts | 51 ++ .../[webhookId]/regenerate-secret/route.ts | 38 ++ .../v1/webhooks/[webhookId]/route.ts | 84 +++ .../app/api/(public-api)/v1/webhooks/route.ts | 60 ++ apps/web/src/lib/api/handler/namespace.ts | 2 +- apps/web/src/lib/api/usage.ts | 43 ++ apps/web/src/lib/auth.ts | 3 + apps/web/src/lib/chat/index.ts | 305 ++++++++++ apps/web/src/lib/mcp/auth.ts | 46 ++ apps/web/src/lib/mcp/index.ts | 43 ++ apps/web/src/lib/mcp/run-tool.ts | 89 +++ apps/web/src/lib/mcp/schemas.ts | 7 + apps/web/src/lib/mcp/tools/api-keys.ts | 90 +++ apps/web/src/lib/mcp/tools/chat.ts | 63 ++ apps/web/src/lib/mcp/tools/documents.ts | 188 ++++++ apps/web/src/lib/mcp/tools/hosting.ts | 212 +++++++ apps/web/src/lib/mcp/tools/ingestion.ts | 252 ++++++++ apps/web/src/lib/mcp/tools/namespaces.ts | 125 ++++ apps/web/src/lib/mcp/tools/organization.ts | 92 +++ apps/web/src/lib/mcp/tools/search.ts | 48 ++ apps/web/src/lib/mcp/tools/webhooks.ts | 179 ++++++ .../src/openapi/v1/api-keys/create-api-key.ts | 44 ++ .../src/openapi/v1/api-keys/delete-api-key.ts | 40 ++ apps/web/src/openapi/v1/api-keys/index.ts | 15 + .../src/openapi/v1/api-keys/list-api-keys.ts | 34 ++ apps/web/src/openapi/v1/chat.ts | 54 ++ apps/web/src/openapi/v1/hosting/add-domain.ts | 41 ++ .../openapi/v1/hosting/check-domain-status.ts | 34 ++ .../src/openapi/v1/hosting/delete-hosting.ts | 13 +- apps/web/src/openapi/v1/hosting/index.ts | 8 + .../src/openapi/v1/hosting/remove-domain.ts | 28 + apps/web/src/openapi/v1/index.ts | 8 + .../openapi/v1/namespaces/delete-namespace.ts | 10 +- .../v1/organization/get-organization.ts | 33 ++ apps/web/src/openapi/v1/organization/index.ts | 15 + .../openapi/v1/organization/list-members.ts | 33 ++ .../v1/organization/update-organization.ts | 44 ++ .../src/openapi/v1/webhooks/create-webhook.ts | 45 ++ .../src/openapi/v1/webhooks/delete-webhook.ts | 29 + .../src/openapi/v1/webhooks/get-webhook.ts | 35 ++ apps/web/src/openapi/v1/webhooks/index.ts | 23 + .../src/openapi/v1/webhooks/list-webhooks.ts | 34 ++ .../v1/webhooks/regenerate-webhook-secret.ts | 36 ++ .../src/openapi/v1/webhooks/update-webhook.ts | 45 ++ apps/web/src/openapi/v1/webhooks/utils.ts | 11 + apps/web/src/schemas/api/api-key.ts | 33 ++ apps/web/src/schemas/api/chat.ts | 95 +++ apps/web/src/schemas/api/hosting.ts | 94 +++ apps/web/src/schemas/api/organization.ts | 98 ++++ apps/web/src/schemas/api/webhook.ts | 98 ++++ apps/web/src/server/api/routers/api-keys.ts | 32 +- apps/web/src/server/api/routers/domains.ts | 221 +------ .../web/src/server/api/routers/ingest-jobs.ts | 117 +--- apps/web/src/server/api/routers/namespaces.ts | 51 +- .../src/server/api/routers/organizations.ts | 34 +- apps/web/src/server/api/routers/webhooks.ts | 210 ++----- apps/web/src/server/api/trpc.ts | 22 +- apps/web/src/services/api-key/delete.ts | 23 + apps/web/src/services/api-key/list.ts | 16 + apps/web/src/services/chat.ts | 16 + apps/web/src/services/documents/delete.ts | 38 ++ apps/web/src/services/documents/download.ts | 68 +++ apps/web/src/services/documents/get.ts | 47 ++ apps/web/src/services/domains/add.ts | 58 ++ apps/web/src/services/domains/check-status.ts | 112 ++++ apps/web/src/services/domains/remove.ts | 44 ++ apps/web/src/services/domains/utils.ts | 16 + apps/web/src/services/hosting/delete.ts | 45 +- apps/web/src/services/hosting/update.ts | 3 + apps/web/src/services/ingest-jobs/create.ts | 115 ++-- apps/web/src/services/ingest-jobs/delete.ts | 30 +- .../web/src/services/ingest-jobs/re-ingest.ts | 100 ++++ apps/web/src/services/namespaces/create.ts | 65 ++- apps/web/src/services/namespaces/delete.ts | 4 + apps/web/src/services/namespaces/update.ts | 48 ++ apps/web/src/services/organizations/get.ts | 49 ++ .../web/src/services/organizations/members.ts | 44 ++ apps/web/src/services/organizations/update.ts | 75 +++ apps/web/src/services/search.ts | 63 ++ apps/web/src/services/webhooks/create.ts | 22 + apps/web/src/services/webhooks/delete.ts | 30 + apps/web/src/services/webhooks/get.ts | 43 ++ apps/web/src/services/webhooks/list.ts | 12 + apps/web/src/services/webhooks/plan.ts | 14 + .../services/webhooks/regenerate-secret.ts | 47 ++ apps/web/src/services/webhooks/toggle.ts | 60 ++ apps/web/src/services/webhooks/update.ts | 145 +++++ bun.lock | 104 ++-- internal-docs/analytics-events.md | 546 ------------------ 114 files changed, 5096 insertions(+), 1963 deletions(-) delete mode 120000 .opencode/skills/baseline-ui delete mode 120000 .opencode/skills/fixing-accessibility delete mode 120000 .opencode/skills/fixing-metadata delete mode 120000 .opencode/skills/react-email delete mode 120000 .opencode/skills/trigger-dev-tasks delete mode 120000 .opencode/skills/vercel-react-best-practices create mode 100644 apps/web/src/app/api/(public-api)/[transport]/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/api-keys/[keyId]/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/api-keys/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/chat/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/domain/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/organization/members/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/organization/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/regenerate-secret/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/webhooks/route.ts create mode 100644 apps/web/src/lib/chat/index.ts create mode 100644 apps/web/src/lib/mcp/auth.ts create mode 100644 apps/web/src/lib/mcp/index.ts create mode 100644 apps/web/src/lib/mcp/run-tool.ts create mode 100644 apps/web/src/lib/mcp/schemas.ts create mode 100644 apps/web/src/lib/mcp/tools/api-keys.ts create mode 100644 apps/web/src/lib/mcp/tools/chat.ts create mode 100644 apps/web/src/lib/mcp/tools/documents.ts create mode 100644 apps/web/src/lib/mcp/tools/hosting.ts create mode 100644 apps/web/src/lib/mcp/tools/ingestion.ts create mode 100644 apps/web/src/lib/mcp/tools/namespaces.ts create mode 100644 apps/web/src/lib/mcp/tools/organization.ts create mode 100644 apps/web/src/lib/mcp/tools/search.ts create mode 100644 apps/web/src/lib/mcp/tools/webhooks.ts create mode 100644 apps/web/src/openapi/v1/api-keys/create-api-key.ts create mode 100644 apps/web/src/openapi/v1/api-keys/delete-api-key.ts create mode 100644 apps/web/src/openapi/v1/api-keys/index.ts create mode 100644 apps/web/src/openapi/v1/api-keys/list-api-keys.ts create mode 100644 apps/web/src/openapi/v1/chat.ts create mode 100644 apps/web/src/openapi/v1/hosting/add-domain.ts create mode 100644 apps/web/src/openapi/v1/hosting/check-domain-status.ts create mode 100644 apps/web/src/openapi/v1/hosting/remove-domain.ts create mode 100644 apps/web/src/openapi/v1/organization/get-organization.ts create mode 100644 apps/web/src/openapi/v1/organization/index.ts create mode 100644 apps/web/src/openapi/v1/organization/list-members.ts create mode 100644 apps/web/src/openapi/v1/organization/update-organization.ts create mode 100644 apps/web/src/openapi/v1/webhooks/create-webhook.ts create mode 100644 apps/web/src/openapi/v1/webhooks/delete-webhook.ts create mode 100644 apps/web/src/openapi/v1/webhooks/get-webhook.ts create mode 100644 apps/web/src/openapi/v1/webhooks/index.ts create mode 100644 apps/web/src/openapi/v1/webhooks/list-webhooks.ts create mode 100644 apps/web/src/openapi/v1/webhooks/regenerate-webhook-secret.ts create mode 100644 apps/web/src/openapi/v1/webhooks/update-webhook.ts create mode 100644 apps/web/src/openapi/v1/webhooks/utils.ts create mode 100644 apps/web/src/schemas/api/api-key.ts create mode 100644 apps/web/src/schemas/api/chat.ts create mode 100644 apps/web/src/schemas/api/organization.ts create mode 100644 apps/web/src/schemas/api/webhook.ts create mode 100644 apps/web/src/services/api-key/delete.ts create mode 100644 apps/web/src/services/api-key/list.ts create mode 100644 apps/web/src/services/chat.ts create mode 100644 apps/web/src/services/documents/download.ts create mode 100644 apps/web/src/services/documents/get.ts create mode 100644 apps/web/src/services/domains/add.ts create mode 100644 apps/web/src/services/domains/check-status.ts create mode 100644 apps/web/src/services/domains/remove.ts create mode 100644 apps/web/src/services/domains/utils.ts create mode 100644 apps/web/src/services/ingest-jobs/re-ingest.ts create mode 100644 apps/web/src/services/namespaces/update.ts create mode 100644 apps/web/src/services/organizations/get.ts create mode 100644 apps/web/src/services/organizations/members.ts create mode 100644 apps/web/src/services/organizations/update.ts create mode 100644 apps/web/src/services/search.ts create mode 100644 apps/web/src/services/webhooks/create.ts create mode 100644 apps/web/src/services/webhooks/delete.ts create mode 100644 apps/web/src/services/webhooks/get.ts create mode 100644 apps/web/src/services/webhooks/list.ts create mode 100644 apps/web/src/services/webhooks/plan.ts create mode 100644 apps/web/src/services/webhooks/regenerate-secret.ts create mode 100644 apps/web/src/services/webhooks/toggle.ts create mode 100644 apps/web/src/services/webhooks/update.ts delete mode 100644 internal-docs/analytics-events.md diff --git a/.opencode/skills/baseline-ui b/.opencode/skills/baseline-ui deleted file mode 120000 index 0593464b..00000000 --- a/.opencode/skills/baseline-ui +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/baseline-ui \ No newline at end of file diff --git a/.opencode/skills/fixing-accessibility b/.opencode/skills/fixing-accessibility deleted file mode 120000 index be2d3531..00000000 --- a/.opencode/skills/fixing-accessibility +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/fixing-accessibility \ No newline at end of file diff --git a/.opencode/skills/fixing-metadata b/.opencode/skills/fixing-metadata deleted file mode 120000 index 56d33ff5..00000000 --- a/.opencode/skills/fixing-metadata +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/fixing-metadata \ No newline at end of file diff --git a/.opencode/skills/react-email b/.opencode/skills/react-email deleted file mode 120000 index 1c972a51..00000000 --- a/.opencode/skills/react-email +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/react-email \ No newline at end of file diff --git a/.opencode/skills/trigger-dev-tasks b/.opencode/skills/trigger-dev-tasks deleted file mode 120000 index 63f01197..00000000 --- a/.opencode/skills/trigger-dev-tasks +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/trigger-dev-tasks \ No newline at end of file diff --git a/.opencode/skills/vercel-react-best-practices b/.opencode/skills/vercel-react-best-practices deleted file mode 120000 index e567923b..00000000 --- a/.opencode/skills/vercel-react-best-practices +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/vercel-react-best-practices \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index baef52a0..68c79abb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@calcom/embed-react": "^1.5.3", "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^5.2.2", + "@modelcontextprotocol/sdk": "1.26.0", "@number-flow/react": "^0.5.10", "@t3-oss/env-nextjs": "catalog:", "@tanstack/react-query": "catalog:", @@ -50,6 +51,7 @@ "fast-deep-equal": "^3.1.3", "file-type": "^21.3.0", "jiti": "^2.6.1", + "mcp-handler": "^1.1.0", "motion": "^12.26.2", "next": "^16.1.6", "posthog-js": "^1.326.0", diff --git a/apps/web/src/app/api/(internal-api)/chat/route.ts b/apps/web/src/app/api/(internal-api)/chat/route.ts index 72e3c22b..79325f6c 100644 --- a/apps/web/src/app/api/(internal-api)/chat/route.ts +++ b/apps/web/src/app/api/(internal-api)/chat/route.ts @@ -1,32 +1,10 @@ -import type { ModelMessage } from "ai"; -import agenticPipeline from "@/lib/agentic"; -import { AgentsetApiError } from "@/lib/api/errors"; import { withAuthApiHandler } from "@/lib/api/handler"; import { parseRequestBody } from "@/lib/api/utils"; -import { DeepResearchPipeline } from "@/lib/deep-research"; -import { - CONDENSE_SYSTEM_PROMPT, - CONDENSE_USER_PROMPT, - NEW_MESSAGE_PROMPT, -} from "@/lib/prompts"; -import { extractTextFromParts } from "@/lib/string-utils"; -import { MyUIMessage } from "@/types/ai"; +import { streamChat } from "@/lib/chat"; import { waitUntil } from "@vercel/functions"; -import { - convertToModelMessages, - createUIMessageStream, - createUIMessageStreamResponse, - generateText, - streamText, -} from "ai"; +import { convertToModelMessages } from "ai"; import { db } from "@agentset/db/client"; -import { - getNamespaceEmbeddingModel, - getNamespaceLanguageModel, - getNamespaceVectorStore, - queryVectorStore, -} from "@agentset/engine"; import { chatSchema } from "./schema"; @@ -58,180 +36,15 @@ export const POST = withAuthApiHandler( async ({ req, namespace, tenantId, headers }) => { const body = await chatSchema.parseAsync(await parseRequestBody(req)); - const messages = convertToModelMessages(body.messages); - const messagesWithoutQuery = messages.slice(0, -1); - const lastMessage = - messages.length > 0 - ? extractTextFromParts(messages[messages.length - 1]!.content) - : null; - - if (!lastMessage) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Messages must contain at least one message", - }); - } - - // TODO: pass namespace config - const [languageModel, vectorStore, embeddingModel] = await Promise.all([ - getNamespaceLanguageModel(body.llmModel), - getNamespaceVectorStore(namespace, tenantId), - getNamespaceEmbeddingModel(namespace, "query"), - ]); - - let query: string; - if (messagesWithoutQuery.length === 0 || body.mode === "agentic") { - query = lastMessage; - } else { - // limit messagesWithoutQuery to the last 10 messages - const messagesToCondense = messagesWithoutQuery.slice(-10); - - // we need to condense the messages + last message into a single query - query = ( - await generateText({ - model: languageModel, - prompt: CONDENSE_SYSTEM_PROMPT.compile({ - question: lastMessage, - chatHistory: CONDENSE_USER_PROMPT.compile({ - query: lastMessage, - chatHistory: messagesToCondense - .map( - (m) => - `- ${m.role === "user" ? "Human" : "Assistant"}: ${m.content as string}`, - ) - .join("\n\n"), - }), - }), - }) - ).text; - } - - if (body.mode === "deepResearch") { - const pipeline = new DeepResearchPipeline({ - modelConfig: { - json: languageModel, - planning: languageModel, - summary: languageModel, - answer: languageModel, - }, - queryOptions: { - embeddingModel, - vectorStore, - topK: body.topK, - minScore: body.minScore, - filter: body.filter, - includeMetadata: body.includeMetadata, - includeRelationships: body.includeRelationships, - rerank: body.rerank - ? { - model: body.rerankModel, - limit: body.rerankLimit, - } - : false, - consistency: "strong", - }, - // maxQueries - }); - - const answer = await pipeline.runResearch(query); - incrementUsage(namespace.id, 1); - - return answer.toUIMessageStreamResponse({ headers }); - } - - if (body.mode === "agentic") { - const result = agenticPipeline({ - model: languageModel, - queryOptions: { - embeddingModel, - vectorStore, - topK: body.topK, - minScore: body.minScore, - filter: body.filter, - includeMetadata: body.includeMetadata, - includeRelationships: body.includeRelationships, - rerank: body.rerank - ? { - model: body.rerankModel, - limit: body.rerankLimit, - } - : false, - consistency: "strong", - }, - systemPrompt: body.systemPrompt, - temperature: body.temperature, - messagesWithoutQuery, - lastMessage, - afterQueries: (totalQueries) => { - incrementUsage(namespace.id, totalQueries); - }, - }); - - return result; - } - - // TODO: track the usage - const data = await queryVectorStore({ - embeddingModel, - vectorStore, - query, - topK: body.topK, - minScore: body.minScore, - filter: body.filter, - includeMetadata: body.includeMetadata, - includeRelationships: body.includeRelationships, - rerank: body.rerank - ? { - model: body.rerankModel, - limit: body.rerankLimit, - } - : false, - consistency: "strong", - }); - - if (!data) { - throw new AgentsetApiError({ - code: "internal_server_error", - message: "Failed to parse chunks", - }); - } - - const newMessages: ModelMessage[] = [ - ...messagesWithoutQuery, - { - role: "user", - content: NEW_MESSAGE_PROMPT.compile({ - chunks: data.results - .map((chunk, idx) => `[${idx + 1}]: ${chunk.text}`) - .join("\n\n"), - query: lastMessage, // put the original query in the message to help with context - }), - }, - ]; - - incrementUsage(namespace.id, 1); - - // add the sources to the stream - const stream = createUIMessageStream({ - execute: ({ writer }) => { - const messageStream = streamText({ - model: languageModel, - system: body.systemPrompt, - messages: newMessages, - temperature: body.temperature, - onError: (error) => { - console.error(error); - }, - }); - - writer.write({ - type: "data-agentset-sources", - data, - }); - writer.merge(messageStream.toUIMessageStream()); + return streamChat({ + namespace, + tenantId, + messages: convertToModelMessages(body.messages), + options: body, + headers, + onUsageIncrement: (queries) => { + incrementUsage(namespace.id, queries); }, }); - - return createUIMessageStreamResponse({ stream, headers }); }, ); diff --git a/apps/web/src/app/api/(internal-api)/chat/schema.ts b/apps/web/src/app/api/(internal-api)/chat/schema.ts index c068409a..2f4244ba 100644 --- a/apps/web/src/app/api/(internal-api)/chat/schema.ts +++ b/apps/web/src/app/api/(internal-api)/chat/schema.ts @@ -1,28 +1,9 @@ -import { DEFAULT_SYSTEM_PROMPT } from "@/lib/prompts"; -import { baseQueryVectorStoreSchema } from "@/schemas/api/query"; +import { chatOptionsSchema } from "@/schemas/api/chat"; import { messagesSchema } from "@/schemas/chat"; -import { z } from "zod/v4"; -import { - llmSchemaWithDefault, - rerankerSchemaWithDefault, -} from "@agentset/validation"; - -export const chatSchema = baseQueryVectorStoreSchema - .omit({ query: true }) +export const chatSchema = chatOptionsSchema .extend({ - systemPrompt: z - .string() - .optional() - .default(DEFAULT_SYSTEM_PROMPT.compile()) - .describe( - "The system prompt to use for the chat. Defaults to the default system prompt.", - ), messages: messagesSchema, - temperature: z.number().optional(), - mode: z.enum(["normal", "agentic", "deepResearch"]).optional(), - rerankModel: rerankerSchemaWithDefault, - llmModel: llmSchemaWithDefault, }) .check((ctx) => { if (ctx.value.rerankLimit && ctx.value.rerankLimit > ctx.value.topK) { diff --git a/apps/web/src/app/api/(public-api)/[transport]/route.ts b/apps/web/src/app/api/(public-api)/[transport]/route.ts new file mode 100644 index 00000000..a6ad01c4 --- /dev/null +++ b/apps/web/src/app/api/(public-api)/[transport]/route.ts @@ -0,0 +1,31 @@ +import { + MCP_SERVER_INFO, + MCP_SERVER_INSTRUCTIONS, + registerTools, +} from "@/lib/mcp"; +import { verifyMcpToken } from "@/lib/mcp/auth"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; + +export const preferredRegion = "iad1"; // make this closer to the DB +export const maxDuration = 120; + +const handler = createMcpHandler( + (server) => { + registerTools(server); + }, + { + serverInfo: MCP_SERVER_INFO, + instructions: MCP_SERVER_INSTRUCTIONS, + }, + { + basePath: "/api", + disableSse: true, + maxDuration: 120, + verboseLogs: false, + }, +); + +// authenticated with an org API key: Authorization: Bearer agentset_xxx +const authHandler = withMcpAuth(handler, verifyMcpToken, { required: true }); + +export { authHandler as GET, authHandler as POST, authHandler as DELETE }; diff --git a/apps/web/src/app/api/(public-api)/v1/api-keys/[keyId]/route.ts b/apps/web/src/app/api/(public-api)/v1/api-keys/[keyId]/route.ts new file mode 100644 index 00000000..53bbd80e --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/api-keys/[keyId]/route.ts @@ -0,0 +1,21 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { withApiHandler } from "@/lib/api/handler"; +import { deleteApiKey } from "@/services/api-key/delete"; + +export const DELETE = withApiHandler( + async ({ organization, params, headers }) => { + const keyId = params.keyId; + if (!keyId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid API key ID.", + }); + } + + // scoped to the organization; a missing key throws P2025 which maps to a 404 + await deleteApiKey({ id: keyId, organizationId: organization.id }); + + return new Response(null, { status: 204, headers }); + }, + { logging: { routeName: "DELETE /v1/api-keys/[keyId]" } }, +); diff --git a/apps/web/src/app/api/(public-api)/v1/api-keys/route.ts b/apps/web/src/app/api/(public-api)/v1/api-keys/route.ts new file mode 100644 index 00000000..e947bb0e --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/api-keys/route.ts @@ -0,0 +1,54 @@ +import { withApiHandler } from "@/lib/api/handler"; +import { makeApiSuccessResponse } from "@/lib/api/response"; +import { parseRequestBody } from "@/lib/api/utils"; +import { + ApiKeySchema, + createApiKeyBodySchema, + CreatedApiKeySchema, +} from "@/schemas/api/api-key"; +import { createApiKey } from "@/services/api-key/create"; +import { listApiKeys } from "@/services/api-key/list"; + +import { prefixId } from "@agentset/utils"; + +export const GET = withApiHandler( + async ({ organization, headers }) => { + const apiKeys = await listApiKeys({ organizationId: organization.id }); + + return makeApiSuccessResponse({ + data: apiKeys.map((apiKey) => + ApiKeySchema.parse({ + ...apiKey, + organizationId: prefixId(apiKey.organizationId, "org_"), + }), + ), + headers, + }); + }, + { logging: { routeName: "GET /v1/api-keys" } }, +); + +export const POST = withApiHandler( + async ({ organization, headers, req }) => { + const { label, scope } = await createApiKeyBodySchema.parseAsync( + await parseRequestBody(req), + ); + + // TODO: check apiScope + const apiKey = await createApiKey({ + organizationId: organization.id, + label, + scope, + }); + + return makeApiSuccessResponse({ + data: CreatedApiKeySchema.parse({ + ...apiKey, + organizationId: prefixId(apiKey.organizationId, "org_"), + }), + headers, + status: 201, + }); + }, + { logging: { routeName: "POST /v1/api-keys" } }, +); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/chat/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/chat/route.ts new file mode 100644 index 00000000..297495c9 --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/chat/route.ts @@ -0,0 +1,56 @@ +import { withNamespaceApiHandler } from "@/lib/api/handler"; +import { makeApiSuccessResponse } from "@/lib/api/response"; +import { + checkSearchLimit, + incrementOrganizationSearchUsage, +} from "@/lib/api/usage"; +import { parseRequestBody } from "@/lib/api/utils"; +import { generateChat, streamChat } from "@/lib/chat"; +import { chatSchema } from "@/schemas/api/chat"; +import { toModelMessages } from "@/services/chat"; + +export const preferredRegion = "iad1"; // make this closer to the DB +export const maxDuration = 120; + +export const POST = withNamespaceApiHandler( + async ({ req, namespace, tenantId, organization, headers }) => { + // TODO: set hard limits to prevent abuse + checkSearchLimit(organization); + + const body = await chatSchema.parseAsync(await parseRequestBody(req)); + + const messages = toModelMessages(body.messages); + + const onUsageIncrement = (queries: number) => { + incrementOrganizationSearchUsage(organization.id, queries); + }; + + if (body.stream) { + return streamChat({ + namespace, + tenantId, + messages, + options: body, + headers, + onUsageIncrement, + }); + } + + const { text, sources } = await generateChat({ + namespace, + tenantId, + messages, + options: body, + onUsageIncrement, + }); + + return makeApiSuccessResponse({ + data: { + message: { role: "assistant", content: text }, + sources, + }, + headers, + }); + }, + { logging: { routeName: "POST /v1/namespace/[namespaceId]/chat" } }, +); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts index 12fae5bd..cfc802a8 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts @@ -1,54 +1,17 @@ -import { AgentsetApiError } from "@/lib/api/errors"; import { withNamespaceApiHandler } from "@/lib/api/handler"; import { makeApiSuccessResponse } from "@/lib/api/response"; - -import { DocumentStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { presignChunksDownloadUrl } from "@agentset/storage"; -import { normalizeId } from "@agentset/utils"; +import { getDocumentChunksDownloadUrl } from "@/services/documents/download"; export const POST = withNamespaceApiHandler( - async ({ params, namespace, headers }) => { - const documentId = normalizeId(params.documentId ?? "", "doc_"); - if (!documentId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid document ID", - }); - } - - const document = await db.document.findUnique({ - where: { - id: documentId, - namespaceId: namespace.id, - }, - select: { id: true, status: true }, + async ({ params, namespace, tenantId, headers }) => { + const data = await getDocumentChunksDownloadUrl({ + namespaceId: namespace.id, + documentId: params.documentId ?? "", + tenantId, }); - if (!document) { - throw new AgentsetApiError({ - code: "not_found", - message: "Document not found", - }); - } - - if (document.status !== DocumentStatus.COMPLETED) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Chunks are only available for completed documents", - }); - } - - const url = await presignChunksDownloadUrl(namespace.id, document.id); - if (!url) { - throw new AgentsetApiError({ - code: "not_found", - message: "Chunks file not found", - }); - } - return makeApiSuccessResponse({ - data: { url }, + data, headers, }); }, diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts index 80cc5318..f08a81ec 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts @@ -1,49 +1,17 @@ -import { AgentsetApiError } from "@/lib/api/errors"; import { withNamespaceApiHandler } from "@/lib/api/handler"; import { makeApiSuccessResponse } from "@/lib/api/response"; - -import { db } from "@agentset/db/client"; -import { presignGetUrl } from "@agentset/storage"; -import { normalizeId } from "@agentset/utils"; +import { getDocumentFileDownloadUrl } from "@/services/documents/download"; export const POST = withNamespaceApiHandler( - async ({ params, namespace, headers }) => { - const documentId = normalizeId(params.documentId ?? "", "doc_"); - if (!documentId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid document ID", - }); - } - - const document = await db.document.findUnique({ - where: { - id: documentId, - namespaceId: namespace.id, - }, - select: { id: true, name: true, source: true }, - }); - - if (!document) { - throw new AgentsetApiError({ - code: "not_found", - message: "Document not found", - }); - } - - if (document.source.type !== "MANAGED_FILE") { - throw new AgentsetApiError({ - code: "bad_request", - message: "File download is only available for managed files", - }); - } - - const { url } = await presignGetUrl(document.source.key, { - fileName: document.name ?? undefined, + async ({ params, namespace, tenantId, headers }) => { + const data = await getDocumentFileDownloadUrl({ + namespaceId: namespace.id, + documentId: params.documentId ?? "", + tenantId, }); return makeApiSuccessResponse({ - data: { url }, + data, headers, }); }, diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts index 8ab5dc21..eb7d4c14 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts @@ -1,37 +1,19 @@ -import { AgentsetApiError } from "@/lib/api/errors"; import { withNamespaceApiHandler } from "@/lib/api/handler"; import { makeApiSuccessResponse } from "@/lib/api/response"; import { DocumentSchema } from "@/schemas/api/document"; -import { deleteDocument } from "@/services/documents/delete"; +import { queueDocumentDeletion } from "@/services/documents/delete"; +import { getDocumentOrThrow } from "@/services/documents/get"; -import { DocumentStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { normalizeId, prefixId } from "@agentset/utils"; +import { prefixId } from "@agentset/utils"; export const GET = withNamespaceApiHandler( - async ({ params, namespace, headers }) => { - const documentId = normalizeId(params.documentId ?? "", "doc_"); - if (!documentId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid document ID", - }); - } - - const doc = await db.document.findUnique({ - where: { - id: documentId, - namespaceId: namespace.id, - }, + async ({ params, namespace, tenantId, headers }) => { + const doc = await getDocumentOrThrow({ + namespaceId: namespace.id, + documentId: params.documentId ?? "", + tenantId, }); - if (!doc) { - throw new AgentsetApiError({ - code: "not_found", - message: "Document not found", - }); - } - return makeApiSuccessResponse({ data: DocumentSchema.parse({ ...doc, @@ -49,47 +31,13 @@ export const GET = withNamespaceApiHandler( ); export const DELETE = withNamespaceApiHandler( - async ({ params, namespace, headers }) => { - const documentId = normalizeId(params.documentId ?? "", "doc_"); - if (!documentId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid document ID", - }); - } - + async ({ params, namespace, tenantId, headers }) => { // TODO: check apiScope - const document = await db.document.findUnique({ - where: { - id: documentId, - namespaceId: namespace.id, - }, - select: { - id: true, - status: true, - }, - }); - - if (!document) { - throw new AgentsetApiError({ - code: "not_found", - message: "Document not found", - }); - } - - if ( - document.status === DocumentStatus.QUEUED_FOR_DELETE || - document.status === DocumentStatus.DELETING - ) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Document is already being deleted", - }); - } - - const data = await deleteDocument({ - documentId: document.id, + const data = await queueDocumentDeletion({ + namespaceId: namespace.id, organizationId: namespace.organizationId, + documentId: params.documentId ?? "", + tenantId, }); return makeApiSuccessResponse({ diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/domain/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/domain/route.ts new file mode 100644 index 00000000..94a6148b --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/domain/route.ts @@ -0,0 +1,92 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { withNamespaceApiHandler } from "@/lib/api/handler/namespace"; +import { makeApiSuccessResponse } from "@/lib/api/response"; +import { parseRequestBody } from "@/lib/api/utils"; +import { + addDomainSchema, + DomainSchema, + DomainStatusSchema, +} from "@/schemas/api/hosting"; +import { addDomain } from "@/services/domains/add"; +import { checkDomainStatus } from "@/services/domains/check-status"; +import { removeDomain } from "@/services/domains/remove"; +import { getHosting } from "@/services/hosting/get"; + +const getHostingOrThrow = async (namespaceId: string) => { + const hosting = await getHosting({ namespaceId }); + + if (!hosting) { + throw new AgentsetApiError({ + code: "not_found", + message: "Hosting is not enabled for this namespace.", + }); + } + + return hosting; +}; + +export const GET = withNamespaceApiHandler( + async ({ namespace, headers }) => { + const hosting = await getHostingOrThrow(namespace.id); + + if (!hosting.domain) { + throw new AgentsetApiError({ + code: "not_found", + message: "No custom domain is set for this namespace.", + }); + } + + const { status, response } = await checkDomainStatus({ + hostingId: hosting.id, + }); + + return makeApiSuccessResponse({ + // the Vercel responses omit fields when the domain is not found, + // the schema defaults fill those in + data: DomainStatusSchema.parse({ + domain: hosting.domain.slug, + status, + verified: status === "Valid Configuration", + apexName: response.domainJson.apexName, + verification: response.domainJson.verification, + conflicts: response.configJson.conflicts, + misconfigured: response.configJson.misconfigured, + }), + headers, + }); + }, + { logging: { routeName: "GET /v1/namespace/[namespaceId]/hosting/domain" } }, +); + +export const POST = withNamespaceApiHandler( + async ({ namespace, headers, req }) => { + const body = await addDomainSchema.parseAsync(await parseRequestBody(req)); + const hosting = await getHostingOrThrow(namespace.id); + + const domain = await addDomain({ + hostingId: hosting.id, + domain: body.domain, + }); + + return makeApiSuccessResponse({ + data: DomainSchema.parse(domain), + headers, + status: 201, + }); + }, + { logging: { routeName: "POST /v1/namespace/[namespaceId]/hosting/domain" } }, +); + +export const DELETE = withNamespaceApiHandler( + async ({ namespace, headers }) => { + const hosting = await getHostingOrThrow(namespace.id); + await removeDomain({ hostingId: hosting.id }); + + return new Response(null, { status: 204, headers }); + }, + { + logging: { + routeName: "DELETE /v1/namespace/[namespaceId]/hosting/domain", + }, + }, +); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts index 01527bdc..96eea7e4 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts @@ -74,25 +74,9 @@ export const PUT = PATCH; export const DELETE = withNamespaceApiHandler( async ({ namespace, headers }) => { - const hosting = await getHosting({ namespaceId: namespace.id }); - - if (!hosting) { - throw new AgentsetApiError({ - code: "not_found", - message: "Hosting not found for this namespace.", - }); - } - await deleteHosting({ namespaceId: namespace.id }); - return makeApiSuccessResponse({ - data: HostingSchema.parse({ - ...hosting, - namespaceId: prefixId(hosting.namespaceId, "ns_"), - }), - headers, - status: 204, - }); + return new Response(null, { status: 204, headers }); }, { logging: { routeName: "DELETE /v1/namespace/[namespaceId]/hosting" } }, ); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts index 07e5ced9..a626b1ab 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts @@ -2,10 +2,7 @@ import { AgentsetApiError } from "@/lib/api/errors"; import { withNamespaceApiHandler } from "@/lib/api/handler"; import { normalizeId, prefixId } from "@agentset/utils"; import { makeApiSuccessResponse } from "@/lib/api/response"; - -import { IngestJobStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { triggerReIngestJob } from "@agentset/jobs"; +import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; export const POST = withNamespaceApiHandler( async ({ params, namespace, headers, organization }) => { @@ -17,66 +14,16 @@ export const POST = withNamespaceApiHandler( }); } - const ingestJob = await db.ingestJob.findUnique({ - where: { - id: jobId, - namespaceId: namespace.id, - }, - select: { - id: true, - status: true, - }, - }); - - if (!ingestJob) { - throw new AgentsetApiError({ - code: "not_found", - message: "Ingest job not found", - }); - } - - if ( - ingestJob.status === IngestJobStatus.QUEUED_FOR_DELETE || - ingestJob.status === IngestJobStatus.DELETING - ) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Ingest job is being deleted", - }); - } - - if ( - ingestJob.status === IngestJobStatus.PRE_PROCESSING || - ingestJob.status === IngestJobStatus.PROCESSING - ) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Ingest job is already being processed", - }); - } - - const handle = await triggerReIngestJob( - { - jobId: ingestJob.id, - }, - organization.plan, - ); - - await db.ingestJob.update({ - where: { id: ingestJob.id }, - data: { - status: IngestJobStatus.QUEUED_FOR_RESYNC, - queuedAt: new Date(), - workflowRunsIds: { push: handle.id }, - }, - select: { - id: true, - }, + const job = await reIngestJob({ + jobId, + namespaceId: namespace.id, + organizationId: namespace.organizationId, + plan: organization.plan, }); return makeApiSuccessResponse({ data: { - id: prefixId(ingestJob.id, "job_"), + id: prefixId(job.id, "job_"), }, headers, }); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts index a2ddd1c4..8d162003 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts @@ -5,7 +5,6 @@ import { makeApiSuccessResponse } from "@/lib/api/response"; import { IngestJobSchema } from "@/schemas/api/ingest-job"; import { deleteIngestJob } from "@/services/ingest-jobs/delete"; -import { IngestJobStatus } from "@agentset/db"; import { db } from "@agentset/db/client"; export const GET = withNamespaceApiHandler( @@ -58,36 +57,9 @@ export const DELETE = withNamespaceApiHandler( }); } - const ingestJob = await db.ingestJob.findUnique({ - where: { - id: jobId, - namespaceId: namespace.id, - }, - select: { - id: true, - status: true, - }, - }); - - if (!ingestJob) { - throw new AgentsetApiError({ - code: "not_found", - message: "Ingest job not found", - }); - } - - if ( - ingestJob.status === IngestJobStatus.QUEUED_FOR_DELETE || - ingestJob.status === IngestJobStatus.DELETING - ) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Ingest job is already being deleted", - }); - } - const data = await deleteIngestJob({ jobId, + namespaceId: namespace.id, organizationId: namespace.organizationId, }); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts index 1b609184..c7bcc987 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts @@ -1,4 +1,4 @@ -import { AgentsetApiError, exceededLimitError } from "@/lib/api/errors"; +import { AgentsetApiError } from "@/lib/api/errors"; import { withNamespaceApiHandler } from "@/lib/api/handler"; import { prefixId } from "@agentset/utils"; import { makeApiSuccessResponse } from "@/lib/api/response"; @@ -11,7 +11,6 @@ import { import { createIngestJob } from "@/services/ingest-jobs/create"; import { getPaginationArgs, paginateResults } from "@/services/pagination"; -import { Prisma } from "@agentset/db"; import { db } from "@agentset/db/client"; import { isFreePlan } from "@agentset/stripe/plans"; @@ -63,78 +62,33 @@ export const GET = withNamespaceApiHandler( export const POST = withNamespaceApiHandler( async ({ req, namespace, tenantId, headers, organization }) => { - // if it's not a pro plan, check if the user has exceeded the limit - // pro plan is unlimited with usage based billing - const isFreeOrg = isFreePlan(organization.plan); - if (isFreeOrg && organization.totalPages >= organization.pagesLimit) { - throw new AgentsetApiError({ - code: "rate_limit_exceeded", - message: exceededLimitError({ - plan: organization.plan, - limit: organization.pagesLimit, - type: "pages", - }), - }); - } - const body = await createIngestJobSchema.parseAsync( await parseRequestBody(req), ); - if (isFreeOrg && body.config?.mode === "accurate") { + if (isFreePlan(organization.plan) && body.config?.mode === "accurate") { throw new AgentsetApiError({ code: "bad_request", message: "Accurate mode requires a pro subscription.", }); } - try { - const job = await createIngestJob({ - data: body, - organizationId: organization.id, - namespaceId: namespace.id, - tenantId, - plan: organization.plan, - }); - - return makeApiSuccessResponse({ - data: IngestJobSchema.parse({ - ...job, - id: prefixId(job.id, "job_"), - namespaceId: prefixId(job.namespaceId, "ns_"), - }), - headers, - status: 201, - }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { - throw new AgentsetApiError({ - code: "conflict", - message: `The external ID "${body.externalId}" is already in use.`, - }); - } - - if (error instanceof Error) { - if (error.message === "INVALID_PAYLOAD") { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid payload", - }); - } - - if (error.message === "FILE_NOT_FOUND") { - throw new AgentsetApiError({ - code: "bad_request", - message: "File not found", - }); - } - } + const job = await createIngestJob({ + data: body, + organization, + namespaceId: namespace.id, + tenantId, + }); - throw error; - } + return makeApiSuccessResponse({ + data: IngestJobSchema.parse({ + ...job, + id: prefixId(job.id, "job_"), + namespaceId: prefixId(job.namespaceId, "ns_"), + }), + headers, + status: 201, + }); }, { logging: { routeName: "POST /v1/namespace/[namespaceId]/ingest-jobs" } }, ); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts index f67bf0d1..07ac88e6 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts @@ -1,4 +1,3 @@ -import { AgentsetApiError } from "@/lib/api/errors"; import { withNamespaceApiHandler } from "@/lib/api/handler"; import { prefixId } from "@agentset/utils"; import { makeApiSuccessResponse } from "@/lib/api/response"; @@ -8,9 +7,7 @@ import { updateNamespaceSchema, } from "@/schemas/api/namespace"; import { deleteNamespace } from "@/services/namespaces/delete"; - -import { Prisma } from "@agentset/db"; -import { db } from "@agentset/db/client"; +import { updateNamespace } from "@/services/namespaces/update"; export const GET = withNamespaceApiHandler( async ({ namespace, headers }) => { @@ -28,43 +25,24 @@ export const GET = withNamespaceApiHandler( export const PATCH = withNamespaceApiHandler( async ({ namespace, headers, req }) => { - const { name, slug } = await updateNamespaceSchema.parseAsync( + const data = await updateNamespaceSchema.parseAsync( await parseRequestBody(req), ); - try { - const updatedNamespace = await db.namespace.update({ - where: { - id: namespace.id, - }, - data: { - ...(name && { name }), - ...(slug && { slug }), - }, - }); - - return makeApiSuccessResponse({ - data: NamespaceSchema.parse({ - ...updatedNamespace, - id: prefixId(updatedNamespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }), - headers, - }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { - throw new AgentsetApiError({ - code: "conflict", - message: `The slug "${slug}" is already in use.`, - }); - } + const updatedNamespace = await updateNamespace({ + namespaceId: namespace.id, + organizationId: namespace.organizationId, + data, + }); - // let the default error handler handle the error - throw error; - } + return makeApiSuccessResponse({ + data: NamespaceSchema.parse({ + ...updatedNamespace, + id: prefixId(updatedNamespace.id, "ns_"), + organizationId: prefixId(namespace.organizationId, "org_"), + }), + headers, + }); }, { logging: { routeName: "PATCH /v1/namespace/[namespaceId]" } }, ); @@ -76,15 +54,7 @@ export const DELETE = withNamespaceApiHandler( // TODO: check apiScope await deleteNamespace({ namespaceId: namespace.id }); - return makeApiSuccessResponse({ - data: NamespaceSchema.parse({ - ...namespace, - id: prefixId(namespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }), - headers, - status: 204, - }); + return new Response(null, { status: 204, headers }); }, { logging: { routeName: "DELETE /v1/namespace/[namespaceId]" } }, ); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts index 516a28b8..3ab2b591 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts @@ -1,105 +1,31 @@ -import { AgentsetApiError, exceededLimitError } from "@/lib/api/errors"; import { withNamespaceApiHandler } from "@/lib/api/handler"; import { makeApiSuccessResponse } from "@/lib/api/response"; +import { + checkSearchLimit, + incrementOrganizationSearchUsage, +} from "@/lib/api/usage"; import { parseRequestBody } from "@/lib/api/utils"; import { queryVectorStoreSchema } from "@/schemas/api/query"; -import { waitUntil } from "@vercel/functions"; - -import type { QueryVectorStoreResult } from "@agentset/engine"; -import { db } from "@agentset/db/client"; -import { - getNamespaceEmbeddingModel, - getNamespaceVectorStore, - queryVectorStore, -} from "@agentset/engine"; -import { INFINITY_NUMBER } from "@agentset/utils"; +import { searchNamespace } from "@/services/search"; export const preferredRegion = "iad1"; // make this closer to the DB export const POST = withNamespaceApiHandler( async ({ req, namespace, tenantId, organization, headers }) => { - // if it's not a pro plan, check if the user has exceeded the limit - // pro plan is unlimited but has INFINITY_NUMBER in the db // TODO: set hard limits to prevent abuse - if ( - INFINITY_NUMBER !== organization.searchLimit && - organization.searchUsage >= organization.searchLimit - ) { - throw new AgentsetApiError({ - code: "rate_limit_exceeded", - message: exceededLimitError({ - plan: organization.plan, - limit: organization.searchLimit, - type: "retrievals", - }), - }); - } + checkSearchLimit(organization); const body = await queryVectorStoreSchema.parseAsync( await parseRequestBody(req), ); - const isPinecone = - namespace.vectorStoreConfig?.provider === "MANAGED_PINECONE" || - namespace.vectorStoreConfig?.provider === "MANAGED_PINECONE_OLD" || - namespace.vectorStoreConfig?.provider === "PINECONE"; - - if (body.mode === "keyword" && isPinecone) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Keyword search is not enabled for this namespace", - }); - } - - const [embeddingModel, vectorStore] = await Promise.all([ - getNamespaceEmbeddingModel(namespace, "query"), - getNamespaceVectorStore(namespace, tenantId), - ]); - - let results: QueryVectorStoreResult["results"] | undefined = []; - - // TODO: track the usage - - results = ( - await queryVectorStore({ - embeddingModel, - vectorStore, - query: body.query, - mode: body.mode, - topK: body.topK, - minScore: body.minScore, - filter: body.filter, - includeMetadata: body.includeMetadata, - includeRelationships: body.includeRelationships, - rerank: body.rerank - ? { - model: body.rerankModel, - limit: body.rerankLimit, - } - : false, - }) - )?.results; - - if (!results) { - throw new AgentsetApiError({ - code: "internal_server_error", - message: "Failed to parse vector store results", - }); - } + const results = await searchNamespace({ + namespace, + tenantId, + options: body, + }); - waitUntil( - (async () => { - // track usage - await db.organization.update({ - where: { - id: organization.id, - }, - data: { - searchUsage: { increment: 1 }, - }, - }); - })(), - ); + incrementOrganizationSearchUsage(organization.id, 1); return makeApiSuccessResponse({ data: results, diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/route.ts index 4b0b8219..3b97bd46 100644 --- a/apps/web/src/app/api/(public-api)/v1/namespace/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/namespace/route.ts @@ -1,4 +1,3 @@ -import { AgentsetApiError } from "@/lib/api/errors"; import { withApiHandler } from "@/lib/api/handler"; import { makeApiSuccessResponse } from "@/lib/api/response"; import { parseRequestBody } from "@/lib/api/utils"; @@ -7,12 +6,8 @@ import { NamespaceSchema, } from "@/schemas/api/namespace"; import { createNamespace } from "@/services/namespaces/create"; -import { - validateEmbeddingModel, - validateVectorStoreConfig, -} from "@/services/namespaces/validate"; -import { Prisma } from "@agentset/db"; +import { NamespaceStatus } from "@agentset/db"; import { db } from "@agentset/db/client"; import { prefixId } from "@agentset/utils"; @@ -21,6 +16,7 @@ export const GET = withApiHandler( const namespaces = await db.namespace.findMany({ where: { organizationId: organization.id, + status: NamespaceStatus.ACTIVE, }, orderBy: { createdAt: "asc", @@ -47,57 +43,21 @@ export const POST = withApiHandler( await parseRequestBody(req), ); - const { success: isValidVectorStore, error: vectorStoreError } = - await validateVectorStoreConfig( - parsed.vectorStoreConfig, - parsed.embeddingConfig, - ); - if (!isValidVectorStore) { - throw new AgentsetApiError({ - code: "bad_request", - message: vectorStoreError, - }); - } - - const { success: isValidEmbedding, error: embeddingError } = - await validateEmbeddingModel(parsed.embeddingConfig); - if (!isValidEmbedding) { - throw new AgentsetApiError({ - code: "bad_request", - message: embeddingError, - }); - } - - try { - // TODO: check apiScope - const namespace = await createNamespace({ - ...parsed, - organizationId: organization.id, - }); - - return makeApiSuccessResponse({ - data: NamespaceSchema.parse({ - ...namespace, - id: prefixId(namespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }), - headers, - status: 201, - }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { - throw new AgentsetApiError({ - code: "conflict", - message: `The slug "${parsed.slug}" is already in use.`, - }); - } + // TODO: check apiScope + const namespace = await createNamespace({ + organizationId: organization.id, + data: parsed, + }); - // let the default error handler handle the error - throw error; - } + return makeApiSuccessResponse({ + data: NamespaceSchema.parse({ + ...namespace, + id: prefixId(namespace.id, "ns_"), + organizationId: prefixId(namespace.organizationId, "org_"), + }), + headers, + status: 201, + }); }, { logging: { routeName: "POST /v1/namespace" } }, ); diff --git a/apps/web/src/app/api/(public-api)/v1/organization/members/route.ts b/apps/web/src/app/api/(public-api)/v1/organization/members/route.ts new file mode 100644 index 00000000..58d14d60 --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/organization/members/route.ts @@ -0,0 +1,26 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { withApiHandler } from "@/lib/api/handler"; +import { makeApiSuccessResponse } from "@/lib/api/response"; +import { OrganizationMembersSchema } from "@/schemas/api/organization"; +import { getOrganizationMembers } from "@/services/organizations/members"; + +export const GET = withApiHandler( + async ({ organization, headers }) => { + const members = await getOrganizationMembers({ + organizationId: organization.id, + }); + + if (!members) { + throw new AgentsetApiError({ + code: "not_found", + message: "Organization not found.", + }); + } + + return makeApiSuccessResponse({ + data: OrganizationMembersSchema.parse(members), + headers, + }); + }, + { logging: { routeName: "GET /v1/organization/members" } }, +); diff --git a/apps/web/src/app/api/(public-api)/v1/organization/route.ts b/apps/web/src/app/api/(public-api)/v1/organization/route.ts new file mode 100644 index 00000000..37637603 --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/organization/route.ts @@ -0,0 +1,51 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { withApiHandler } from "@/lib/api/handler"; +import { makeApiSuccessResponse } from "@/lib/api/response"; +import { parseRequestBody } from "@/lib/api/utils"; +import { updateOrganizationSchema } from "@/schemas/api/organization"; +import { + getOrganization, + toOrganizationResponse, +} from "@/services/organizations/get"; +import { updateOrganization } from "@/services/organizations/update"; + +export const GET = withApiHandler( + async ({ organization, headers }) => { + const org = await getOrganization({ organizationId: organization.id }); + + if (!org) { + throw new AgentsetApiError({ + code: "not_found", + message: "Organization not found.", + }); + } + + return makeApiSuccessResponse({ + data: toOrganizationResponse(org), + headers, + }); + }, + { logging: { routeName: "GET /v1/organization" } }, +); + +export const PATCH = withApiHandler( + async ({ organization, headers, req }) => { + const { name, slug } = await updateOrganizationSchema.parseAsync( + await parseRequestBody(req), + ); + + const updatedOrganization = await updateOrganization({ + organizationId: organization.id, + name, + slug, + }); + + return makeApiSuccessResponse({ + data: toOrganizationResponse(updatedOrganization), + headers, + }); + }, + { logging: { routeName: "PATCH /v1/organization" } }, +); + +export const PUT = PATCH; diff --git a/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/regenerate-secret/route.ts b/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/regenerate-secret/route.ts new file mode 100644 index 00000000..c47fa54c --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/regenerate-secret/route.ts @@ -0,0 +1,38 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { withApiHandler } from "@/lib/api/handler"; +import { makeApiSuccessResponse } from "@/lib/api/response"; +import { WebhookSchema } from "@/schemas/api/webhook"; +import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; + +import { prefixId } from "@agentset/utils"; + +export const POST = withApiHandler( + async ({ organization, params, headers }) => { + const webhook = await regenerateWebhookSecret({ + organizationId: organization.id, + webhookId: params.webhookId ?? "", + }); + + if (!webhook) { + throw new AgentsetApiError({ + code: "not_found", + message: "Webhook not found.", + }); + } + + return makeApiSuccessResponse({ + data: WebhookSchema.parse({ + ...webhook, + namespaceIds: (webhook.namespaceIds ?? []).map((id) => + prefixId(id, "ns_"), + ), + }), + headers, + }); + }, + { + logging: { + routeName: "POST /v1/webhooks/[webhookId]/regenerate-secret", + }, + }, +); diff --git a/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/route.ts b/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/route.ts new file mode 100644 index 00000000..6f977654 --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/route.ts @@ -0,0 +1,84 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { withApiHandler } from "@/lib/api/handler"; +import { makeApiSuccessResponse } from "@/lib/api/response"; +import { parseRequestBody } from "@/lib/api/utils"; +import { + updateWebhookSchema, + WebhookDetailsSchema, + WebhookSchema, +} from "@/schemas/api/webhook"; +import { deleteWebhook } from "@/services/webhooks/delete"; +import { getWebhook } from "@/services/webhooks/get"; +import { applyWebhookUpdate } from "@/services/webhooks/update"; + +import type { WebhookProps } from "@agentset/webhooks"; +import { prefixId } from "@agentset/utils"; + +const webhookNotFoundError = () => + new AgentsetApiError({ + code: "not_found", + message: "Webhook not found.", + }); + +const prefixNamespaceIds = (webhook: WebhookProps) => ({ + ...webhook, + namespaceIds: (webhook.namespaceIds ?? []).map((id) => prefixId(id, "ns_")), +}); + +export const GET = withApiHandler( + async ({ organization, params, headers }) => { + const webhook = await getWebhook({ + organizationId: organization.id, + webhookId: params.webhookId ?? "", + }); + + if (!webhook) { + throw webhookNotFoundError(); + } + + return makeApiSuccessResponse({ + data: WebhookDetailsSchema.parse(prefixNamespaceIds(webhook)), + headers, + }); + }, + { logging: { routeName: "GET /v1/webhooks/[webhookId]" } }, +); + +export const PATCH = withApiHandler( + async ({ organization, params, headers, req }) => { + const body = await updateWebhookSchema.parseAsync( + await parseRequestBody(req), + ); + + const webhook = await applyWebhookUpdate({ + organizationId: organization.id, + plan: organization.plan, + webhookId: params.webhookId ?? "", + ...body, + }); + + return makeApiSuccessResponse({ + data: WebhookSchema.parse(prefixNamespaceIds(webhook)), + headers, + }); + }, + { logging: { routeName: "PATCH /v1/webhooks/[webhookId]" } }, +); + +export const PUT = PATCH; + +export const DELETE = withApiHandler( + async ({ organization, params, headers }) => { + const webhook = await deleteWebhook({ + organizationId: organization.id, + webhookId: params.webhookId ?? "", + }); + + if (!webhook) { + throw webhookNotFoundError(); + } + + return new Response(null, { status: 204, headers }); + }, + { logging: { routeName: "DELETE /v1/webhooks/[webhookId]" } }, +); diff --git a/apps/web/src/app/api/(public-api)/v1/webhooks/route.ts b/apps/web/src/app/api/(public-api)/v1/webhooks/route.ts new file mode 100644 index 00000000..540ad130 --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/webhooks/route.ts @@ -0,0 +1,60 @@ +import { withApiHandler } from "@/lib/api/handler"; +import { makeApiSuccessResponse } from "@/lib/api/response"; +import { parseRequestBody } from "@/lib/api/utils"; +import { + createWebhookSchema, + WebhookSchema, + WebhookSummarySchema, +} from "@/schemas/api/webhook"; +import { createWebhook } from "@/services/webhooks/create"; +import { listWebhooks } from "@/services/webhooks/list"; +import { requireWebhooksPlan } from "@/services/webhooks/plan"; + +import { normalizeId, prefixId } from "@agentset/utils"; + +export const GET = withApiHandler( + async ({ organization, headers }) => { + const webhooks = await listWebhooks({ organizationId: organization.id }); + + return makeApiSuccessResponse({ + data: webhooks.map((webhook) => + WebhookSummarySchema.parse({ + ...webhook, + namespaceIds: (webhook.namespaceIds ?? []).map((id) => + prefixId(id, "ns_"), + ), + }), + ), + headers, + }); + }, + { logging: { routeName: "GET /v1/webhooks" } }, +); + +export const POST = withApiHandler( + async ({ organization, req, headers }) => { + requireWebhooksPlan(organization.plan); + + const parsed = await createWebhookSchema.parseAsync( + await parseRequestBody(req), + ); + + const webhook = await createWebhook({ + ...parsed, + namespaceIds: parsed.namespaceIds?.map((id) => normalizeId(id, "ns_")), + organizationId: organization.id, + }); + + return makeApiSuccessResponse({ + data: WebhookSchema.parse({ + ...webhook, + namespaceIds: (webhook.namespaceIds ?? []).map((id) => + prefixId(id, "ns_"), + ), + }), + headers, + status: 201, + }); + }, + { logging: { routeName: "POST /v1/webhooks" } }, +); diff --git a/apps/web/src/lib/api/handler/namespace.ts b/apps/web/src/lib/api/handler/namespace.ts index 92f6dc8d..eca8917e 100644 --- a/apps/web/src/lib/api/handler/namespace.ts +++ b/apps/web/src/lib/api/handler/namespace.ts @@ -22,7 +22,7 @@ interface NamespaceHandler { ): Promise; } -const getNamespace = async ({ +export const getNamespace = async ({ namespaceId, organizationId, }: { diff --git a/apps/web/src/lib/api/usage.ts b/apps/web/src/lib/api/usage.ts index 0433dd69..e1dbaa1c 100644 --- a/apps/web/src/lib/api/usage.ts +++ b/apps/web/src/lib/api/usage.ts @@ -1,6 +1,8 @@ +import { AgentsetApiError, exceededLimitError } from "@/lib/api/errors"; import { waitUntil } from "@vercel/functions"; import { db } from "@agentset/db/client"; +import { INFINITY_NUMBER } from "@agentset/utils"; export const incrementSearchUsage = (namespaceId: string, queries: number) => { // track usage @@ -21,3 +23,44 @@ export const incrementSearchUsage = (namespaceId: string, queries: number) => { })(), ); }; + +export const incrementOrganizationSearchUsage = ( + organizationId: string, + queries: number, +) => { + // track usage + waitUntil( + (async () => { + await db.organization.update({ + where: { + id: organizationId, + }, + data: { + searchUsage: { increment: queries }, + }, + }); + })(), + ); +}; + +export const checkSearchLimit = (organization: { + plan: string; + searchLimit: number; + searchUsage: number; +}) => { + // if it's not a pro plan, check if the user has exceeded the limit + // pro plan is unlimited but has INFINITY_NUMBER in the db + if ( + INFINITY_NUMBER !== organization.searchLimit && + organization.searchUsage >= organization.searchLimit + ) { + throw new AgentsetApiError({ + code: "rate_limit_exceeded", + message: exceededLimitError({ + plan: organization.plan, + limit: organization.searchLimit, + type: "retrievals", + }), + }); + } +}; diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 5d21d930..eb52b7df 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -55,6 +55,9 @@ export const makeAuth = (params?: { baseUrl: string; isHosting: boolean }) => { plugins: [ admin(), organization({ + // org deletion must go through services/organizations/delete.ts (tRPC organization.delete) + // so subscriptions are canceled and namespaces are cleaned up + disableOrganizationDeletion: true, organizationHooks: { async afterCreateOrganization(data) { // create default api key diff --git a/apps/web/src/lib/chat/index.ts b/apps/web/src/lib/chat/index.ts new file mode 100644 index 00000000..fe837538 --- /dev/null +++ b/apps/web/src/lib/chat/index.ts @@ -0,0 +1,305 @@ +import type { chatOptionsSchema } from "@/schemas/api/chat"; +import type { LanguageModel, ModelMessage } from "ai"; +import type { z } from "zod/v4"; +import agenticPipeline, { generateAgenticResponse } from "@/lib/agentic"; +import { AgentsetApiError } from "@/lib/api/errors"; +import { DeepResearchPipeline } from "@/lib/deep-research"; +import { + CONDENSE_SYSTEM_PROMPT, + CONDENSE_USER_PROMPT, + NEW_MESSAGE_PROMPT, +} from "@/lib/prompts"; +import { extractTextFromParts } from "@/lib/string-utils"; +import type { MyUIMessage } from "@/types/ai"; +import { + createUIMessageStream, + createUIMessageStreamResponse, + generateText, + streamText, +} from "ai"; + +import type { Namespace } from "@agentset/db"; +import type { QueryVectorStoreResult } from "@agentset/engine"; +import { + getNamespaceEmbeddingModel, + getNamespaceLanguageModel, + getNamespaceVectorStore, + queryVectorStore, +} from "@agentset/engine"; + +export type ChatOptions = z.infer; + +interface ChatPipelineParams { + namespace: Pick; + tenantId?: string; + messages: ModelMessage[]; + options: ChatOptions; + onUsageIncrement?: (queries: number) => void; +} + +const prepareChat = async ({ + namespace, + tenantId, + messages, + options, +}: Pick< + ChatPipelineParams, + "namespace" | "tenantId" | "messages" | "options" +>) => { + const messagesWithoutQuery = messages.slice(0, -1); + const lastMessage = + messages.length > 0 + ? extractTextFromParts(messages[messages.length - 1]!.content) + : null; + + if (!lastMessage) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Messages must contain at least one message", + }); + } + + if (messages[messages.length - 1]!.role !== "user") { + throw new AgentsetApiError({ + code: "bad_request", + message: "The last message must be from the user", + }); + } + + // TODO: pass namespace config + const languageModel = getNamespaceLanguageModel(options.llmModel); + const [vectorStore, embeddingModel] = await Promise.all([ + getNamespaceVectorStore(namespace, tenantId), + getNamespaceEmbeddingModel(namespace, "query"), + ]); + + return { + messagesWithoutQuery, + lastMessage, + languageModel, + queryOptions: { + embeddingModel, + vectorStore, + topK: options.topK, + minScore: options.minScore, + filter: options.filter, + includeMetadata: options.includeMetadata, + includeRelationships: options.includeRelationships, + rerank: options.rerank + ? { + model: options.rerankModel, + limit: options.rerankLimit, + } + : (false as const), + consistency: "strong" as const, + }, + }; +}; + +const condenseQuery = async ({ + languageModel, + messagesWithoutQuery, + lastMessage, +}: { + languageModel: LanguageModel; + messagesWithoutQuery: ModelMessage[]; + lastMessage: string; +}) => { + if (messagesWithoutQuery.length === 0) return lastMessage; + + // limit messagesWithoutQuery to the last 10 messages + const messagesToCondense = messagesWithoutQuery.slice(-10); + + // we need to condense the messages + last message into a single query + return ( + await generateText({ + model: languageModel, + prompt: CONDENSE_SYSTEM_PROMPT.compile({ + question: lastMessage, + chatHistory: CONDENSE_USER_PROMPT.compile({ + query: lastMessage, + chatHistory: messagesToCondense + .map( + (m) => + `- ${m.role === "user" ? "Human" : "Assistant"}: ${m.content as string}`, + ) + .join("\n\n"), + }), + }), + }) + ).text; +}; + +const makeDeepResearchPipeline = ({ + languageModel, + queryOptions, +}: { + languageModel: LanguageModel; + queryOptions: Awaited>["queryOptions"]; +}) => + new DeepResearchPipeline({ + modelConfig: { + json: languageModel, + planning: languageModel, + summary: languageModel, + answer: languageModel, + }, + queryOptions, + // maxQueries + }); + +const makeRagMessages = ({ + messagesWithoutQuery, + lastMessage, + data, +}: { + messagesWithoutQuery: ModelMessage[]; + lastMessage: string; + data: QueryVectorStoreResult; +}): ModelMessage[] => [ + ...messagesWithoutQuery, + { + role: "user", + content: NEW_MESSAGE_PROMPT.compile({ + chunks: data.results + .map((chunk, idx) => `[${idx + 1}]: ${chunk.text}`) + .join("\n\n"), + query: lastMessage, // put the original query in the message to help with context + }), + }, +]; + +export const streamChat = async ({ + namespace, + tenantId, + messages, + options, + headers, + onUsageIncrement, +}: ChatPipelineParams & { + headers?: Record; +}): Promise => { + const { messagesWithoutQuery, lastMessage, languageModel, queryOptions } = + await prepareChat({ namespace, tenantId, messages, options }); + + if (options.mode === "agentic") { + return agenticPipeline({ + model: languageModel, + queryOptions, + systemPrompt: options.systemPrompt, + temperature: options.temperature, + messagesWithoutQuery, + lastMessage, + afterQueries: (totalQueries) => { + onUsageIncrement?.(totalQueries); + }, + headers, + }); + } + + const query = await condenseQuery({ + languageModel, + messagesWithoutQuery, + lastMessage, + }); + + if (options.mode === "deepResearch") { + const pipeline = makeDeepResearchPipeline({ languageModel, queryOptions }); + + const answer = await pipeline.runResearch(query); + onUsageIncrement?.(1); + + return answer.toUIMessageStreamResponse({ headers }); + } + + const data = await queryVectorStore({ ...queryOptions, query }); + + const newMessages = makeRagMessages({ + messagesWithoutQuery, + lastMessage, + data, + }); + + onUsageIncrement?.(1); + + // add the sources to the stream + const stream = createUIMessageStream({ + execute: ({ writer }) => { + const messageStream = streamText({ + model: languageModel, + system: options.systemPrompt, + messages: newMessages, + temperature: options.temperature, + onError: (error) => { + console.error(error); + }, + }); + + writer.write({ + type: "data-agentset-sources", + data, + }); + writer.merge(messageStream.toUIMessageStream()); + }, + }); + + return createUIMessageStreamResponse({ stream, headers }); +}; + +export const generateChat = async ({ + namespace, + tenantId, + messages, + options, + onUsageIncrement, +}: ChatPipelineParams): Promise<{ + text: string; + sources?: QueryVectorStoreResult["results"]; +}> => { + const { messagesWithoutQuery, lastMessage, languageModel, queryOptions } = + await prepareChat({ namespace, tenantId, messages, options }); + + if (options.mode === "agentic") { + const { answer, sources } = await generateAgenticResponse({ + model: languageModel, + queryOptions, + systemPrompt: options.systemPrompt, + temperature: options.temperature, + messagesWithoutQuery, + lastMessage, + afterQueries: (totalQueries) => { + onUsageIncrement?.(totalQueries); + }, + }); + + return { text: answer, sources }; + } + + const query = await condenseQuery({ + languageModel, + messagesWithoutQuery, + lastMessage, + }); + + if (options.mode === "deepResearch") { + const pipeline = makeDeepResearchPipeline({ languageModel, queryOptions }); + + const answer = await pipeline.runResearch(query); + onUsageIncrement?.(1); + + return { text: await answer.text }; + } + + const data = await queryVectorStore({ ...queryOptions, query }); + + onUsageIncrement?.(1); + + const result = await generateText({ + model: languageModel, + system: options.systemPrompt, + messages: makeRagMessages({ messagesWithoutQuery, lastMessage, data }), + temperature: options.temperature, + }); + + return { text: result.text, sources: data.results }; +}; diff --git a/apps/web/src/lib/mcp/auth.ts b/apps/web/src/lib/mcp/auth.ts new file mode 100644 index 00000000..21e46f22 --- /dev/null +++ b/apps/web/src/lib/mcp/auth.ts @@ -0,0 +1,46 @@ +import type { ApiKeyInfo } from "@/lib/api/api-key"; +import type { NextRequest } from "next/server"; +import { getApiKeyInfo } from "@/lib/api/api-key"; +import { getTenantFromRequest } from "@/lib/api/tenant"; + +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { tryCatch } from "@agentset/utils"; + +export interface McpAuthContext { + organizationId: string; + organization: ApiKeyInfo["organization"]; + tenantId?: string; +} + +// resolves an org API key (Bearer agentset_...) to the same org info the REST v1 handlers use +export const verifyMcpToken = async ( + req: Request, + bearerToken?: string, +): Promise => { + if (!bearerToken?.startsWith("agentset_")) return undefined; + + const orgApiKey = await tryCatch(getApiKeyInfo(bearerToken)); + if (!orgApiKey.data) return undefined; + + let tenantId: string | undefined; + try { + // only reads the x-tenant-id header, so a plain Request is fine + tenantId = getTenantFromRequest(req as NextRequest); + } catch { + // invalid x-tenant-id header + return undefined; + } + + const authContext: McpAuthContext = { + organizationId: orgApiKey.data.organizationId, + organization: orgApiKey.data.organization, + tenantId, + }; + + return { + token: bearerToken, + clientId: orgApiKey.data.organizationId, + scopes: [orgApiKey.data.scope], + extra: authContext as unknown as Record, + }; +}; diff --git a/apps/web/src/lib/mcp/index.ts b/apps/web/src/lib/mcp/index.ts new file mode 100644 index 00000000..76971db9 --- /dev/null +++ b/apps/web/src/lib/mcp/index.ts @@ -0,0 +1,43 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { registerApiKeyTools } from "./tools/api-keys"; +import { registerChatTools } from "./tools/chat"; +import { registerDocumentTools } from "./tools/documents"; +import { registerHostingTools } from "./tools/hosting"; +import { registerIngestionTools } from "./tools/ingestion"; +import { registerNamespaceTools } from "./tools/namespaces"; +import { registerOrganizationTools } from "./tools/organization"; +import { registerSearchTools } from "./tools/search"; +import { registerWebhookTools } from "./tools/webhooks"; + +export const MCP_SERVER_INFO = { + name: "agentset", + version: "1.0.0", +}; + +export const MCP_SERVER_INSTRUCTIONS = `Agentset is a RAG-as-a-service platform. This server operates one organization, determined by the API key. + +Standard flow to make documents searchable: +1. create_namespace (or list_namespaces to find an existing one). +2. create_upload_urls for local files, then PUT each file's bytes to its presigned URL. Skip this step for raw text, public file URLs, websites, or YouTube videos. +3. create_ingest_job with the matching payload type (MANAGED_FILE keys from step 2, TEXT, FILE, CRAWL, YOUTUBE, or BATCH). +4. Poll get_ingest_job until the status is COMPLETED (or check list_documents). +5. search for raw chunks, or chat for a generated answer with sources. + +Multi-tenant partitioning: pass an \`x-tenant-id\` HTTP header on the MCP connection to scope ingestion, documents, search, and chat to that tenant. + +Optionally, enable_hosting publishes a shareable chat/search page for a namespace; update_hosting customizes it and set_custom_domain attaches your own domain. + +IDs are prefixed (org_, ns_, job_, doc_) and tools accept them with or without the prefix. Errors are returned as JSON: {"error": {"code", "message"}}.`; + +export const registerTools = (server: McpServer) => { + registerOrganizationTools(server); + registerApiKeyTools(server); + registerNamespaceTools(server); + registerIngestionTools(server); + registerDocumentTools(server); + registerSearchTools(server); + registerChatTools(server); + registerHostingTools(server); + registerWebhookTools(server); +}; diff --git a/apps/web/src/lib/mcp/run-tool.ts b/apps/web/src/lib/mcp/run-tool.ts new file mode 100644 index 00000000..a4df10d2 --- /dev/null +++ b/apps/web/src/lib/mcp/run-tool.ts @@ -0,0 +1,89 @@ +import type { McpAuthContext } from "@/lib/mcp/auth"; +import { AgentsetApiError, handleApiError } from "@/lib/api/errors"; +import { getNamespace } from "@/lib/api/handler/namespace"; +import { ratelimit } from "@/lib/api/rate-limit"; + +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { + CallToolResult, + ServerNotification, + ServerRequest, +} from "@modelcontextprotocol/sdk/types.js"; +import { normalizeId } from "@agentset/utils"; + +type ToolExtra = RequestHandlerExtra; + +export const toolSuccess = (data: unknown): CallToolResult => ({ + content: [{ type: "text", text: JSON.stringify(data) }], +}); + +export const toolError = (code: string, message: string): CallToolResult => ({ + isError: true, + content: [ + { type: "text", text: JSON.stringify({ error: { code, message } }) }, + ], +}); + +// shared wrapper for every MCP tool: applies the same per-org rate limit as the +// REST v1 handlers and maps business errors to tool error results (never a +// protocol-level failure). +export const runTool = async ( + extra: ToolExtra, + handler: (ctx: McpAuthContext) => Promise, +): Promise => { + const ctx = extra.authInfo?.extra as McpAuthContext | undefined; + if (!ctx?.organizationId) { + return toolError("unauthorized", "Unauthorized: Invalid API key."); + } + + try { + const { success, reset } = await ratelimit( + ctx.organization.apiRatelimit, + "1 m", + ).limit(ctx.organizationId); + + if (!success) { + return toolError( + "rate_limit_exceeded", + `Too many requests. Retry after ${new Date(reset).toISOString()}.`, + ); + } + + const data = await handler(ctx); + return toolSuccess(data); + } catch (error) { + // maps AgentsetApiError, ZodError, and Prisma not-found errors the same + // way the REST error handler does + const { error: apiError } = handleApiError(error); + return toolError(apiError.code, apiError.message); + } +}; + +// resolves a namespace like withNamespaceApiHandler does: normalize the ns_ +// prefix, must be ACTIVE and belong to the key's organization +export const resolveNamespace = async ( + ctx: McpAuthContext, + rawNamespaceId: string, +) => { + const namespaceId = normalizeId(rawNamespaceId, "ns_"); + if (!namespaceId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid namespace ID.", + }); + } + + const namespace = await getNamespace({ + namespaceId, + organizationId: ctx.organizationId, + }); + + if (!namespace) { + throw new AgentsetApiError({ + code: "unauthorized", + message: "Unauthorized: You don't have access to this namespace.", + }); + } + + return namespace; +}; diff --git a/apps/web/src/lib/mcp/schemas.ts b/apps/web/src/lib/mcp/schemas.ts new file mode 100644 index 00000000..59096568 --- /dev/null +++ b/apps/web/src/lib/mcp/schemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const namespaceIdSchema = z + .string() + .describe( + "The ID of the namespace to operate on (e.g. `ns_xxx`). Use list_namespaces to find it.", + ); diff --git a/apps/web/src/lib/mcp/tools/api-keys.ts b/apps/web/src/lib/mcp/tools/api-keys.ts new file mode 100644 index 00000000..e15b23e6 --- /dev/null +++ b/apps/web/src/lib/mcp/tools/api-keys.ts @@ -0,0 +1,90 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { runTool } from "@/lib/mcp/run-tool"; +import { + ApiKeySchema, + createApiKeyBodySchema, + CreatedApiKeySchema, +} from "@/schemas/api/api-key"; +import { createApiKey } from "@/services/api-key/create"; +import { deleteApiKey } from "@/services/api-key/delete"; +import { listApiKeys } from "@/services/api-key/list"; +import { z } from "zod/v4"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { prefixId } from "@agentset/utils"; + +export const registerApiKeyTools = (server: McpServer) => { + server.registerTool( + "list_api_keys", + { + title: "List API keys", + description: + "List the organization's API keys (label, scope, and timestamps). The key material itself is never returned; it is only visible once, when a key is created.", + }, + async (extra) => + runTool(extra, async (ctx) => { + const apiKeys = await listApiKeys({ + organizationId: ctx.organizationId, + }); + + return apiKeys.map((apiKey) => + ApiKeySchema.parse({ + ...apiKey, + organizationId: prefixId(apiKey.organizationId, "org_"), + }), + ); + }), + ); + + server.registerTool( + "create_api_key", + { + title: "Create API key", + description: + "Create a new API key for the organization. The response includes the full key (starts with `agentset_`) exactly once — store it securely, it cannot be retrieved again.", + inputSchema: createApiKeyBodySchema.shape, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const apiKey = await createApiKey({ + organizationId: ctx.organizationId, + label: args.label, + scope: args.scope, + }); + + return CreatedApiKeySchema.parse({ + ...apiKey, + organizationId: prefixId(apiKey.organizationId, "org_"), + }); + }), + ); + + server.registerTool( + "revoke_api_key", + { + title: "Revoke API key", + description: + "Permanently revoke (delete) an API key by its ID. Use list_api_keys to find the ID. Revoking the key you are currently authenticated with will break this connection.", + inputSchema: { + keyId: z.string().describe("The ID of the API key to revoke."), + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + if (!args.keyId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid API key ID.", + }); + } + + // scoped to the organization; a missing key throws P2025 which maps to not_found + const apiKey = await deleteApiKey({ + id: args.keyId, + organizationId: ctx.organizationId, + }); + + return { deleted: true, id: apiKey.id }; + }), + ); +}; diff --git a/apps/web/src/lib/mcp/tools/chat.ts b/apps/web/src/lib/mcp/tools/chat.ts new file mode 100644 index 00000000..5a5410d4 --- /dev/null +++ b/apps/web/src/lib/mcp/tools/chat.ts @@ -0,0 +1,63 @@ +import { + checkSearchLimit, + incrementOrganizationSearchUsage, +} from "@/lib/api/usage"; +import { generateChat } from "@/lib/chat"; +import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; +import { namespaceIdSchema } from "@/lib/mcp/schemas"; +import { + chatMessageSchema, + chatOptionsSchema, + chatSchema, +} from "@/schemas/api/chat"; +import { toModelMessages } from "@/services/chat"; +import { z } from "zod/v4"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export const registerChatTools = (server: McpServer) => { + server.registerTool( + "chat", + { + title: "Chat with a namespace", + description: + "Generate a grounded (RAG) answer from the documents of a namespace, with the retrieved chunks returned as sources. Modes: `normal` (single retrieval, fast), `agentic` (the model generates and evaluates its own search queries, slower but more thorough), and `deepResearch` (iterative research pipeline — slow, can take a minute or more, and does not return sources). Counts toward the organization's search quota (agentic mode counts one search per query it runs). Non-streaming; for streaming use the REST API.", + inputSchema: { + namespaceId: namespaceIdSchema, + messages: z + .array(chatMessageSchema) + .min(1) + .describe( + "The conversation so far. The last message must be from the user, it will be used as the query.", + ), + ...chatOptionsSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + checkSearchLimit(ctx.organization); + + const namespace = await resolveNamespace(ctx, args.namespaceId); + const { namespaceId: _namespaceId, ...rest } = args; + + // re-parse with the REST chat schema to apply the same cross-field + // validation (e.g. rerankLimit <= topK) + const body = await chatSchema.parseAsync({ ...rest, stream: false }); + + const { text, sources } = await generateChat({ + namespace, + tenantId: ctx.tenantId, + messages: toModelMessages(body.messages), + options: body, + onUsageIncrement: (queries) => { + incrementOrganizationSearchUsage(ctx.organizationId, queries); + }, + }); + + return { + message: { role: "assistant", content: text }, + sources, + }; + }), + ); +}; diff --git a/apps/web/src/lib/mcp/tools/documents.ts b/apps/web/src/lib/mcp/tools/documents.ts new file mode 100644 index 00000000..d6284415 --- /dev/null +++ b/apps/web/src/lib/mcp/tools/documents.ts @@ -0,0 +1,188 @@ +import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; +import { namespaceIdSchema } from "@/lib/mcp/schemas"; +import { DocumentSchema } from "@/schemas/api/document"; +import { paginationSchema } from "@/schemas/api/pagination"; +import { queueDocumentDeletion } from "@/services/documents/delete"; +import { + getDocumentChunksDownloadUrl, + getDocumentFileDownloadUrl, +} from "@/services/documents/download"; +import { getDocumentOrThrow } from "@/services/documents/get"; +import { getPaginationArgs, paginateResults } from "@/services/pagination"; +import { z } from "zod/v4"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Document } from "@agentset/db"; +import { DocumentStatusSchema } from "@agentset/db"; +import { db } from "@agentset/db/client"; +import { normalizeId, prefixId } from "@agentset/utils"; + +const documentIdSchema = z + .string() + .describe("The ID of the document (e.g. `doc_xxx`)."); + +const toDocumentResponse = (doc: Document) => + DocumentSchema.parse({ + ...doc, + id: prefixId(doc.id, "doc_"), + ingestJobId: prefixId(doc.ingestJobId, "job_"), + }); + +export const registerDocumentTools = (server: McpServer) => { + server.registerTool( + "list_documents", + { + title: "List documents", + description: + "List the documents of a namespace with cursor pagination. Pass the returned `pagination.nextCursor` as `cursor` to fetch the next page. Optionally filter by statuses or by the ingest job that created them.", + inputSchema: { + namespaceId: namespaceIdSchema, + statuses: z + .array(DocumentStatusSchema) + .optional() + .describe("Statuses to filter by."), + orderBy: z.enum(["createdAt"]).optional().default("createdAt"), + order: z.enum(["asc", "desc"]).optional().default("desc"), + ingestJobId: z + .string() + .optional() + .describe("The ingest job ID to filter documents by."), + ...paginationSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + + const { where, ...paginationArgs } = getPaginationArgs( + args, + { + orderBy: args.orderBy, + order: args.order, + }, + "doc_", + ); + + const documents = await db.document.findMany({ + where: { + tenantId: ctx.tenantId, + namespaceId: namespace.id, + ...(args.ingestJobId && { + ingestJobId: normalizeId(args.ingestJobId, "job_"), + }), + ...(args.statuses && + args.statuses.length > 0 && { status: { in: args.statuses } }), + ...where, + }, + ...paginationArgs, + }); + + const paginated = paginateResults( + args, + documents.map(toDocumentResponse), + ); + + return { + records: paginated.records, + pagination: paginated.pagination, + }; + }), + ); + + server.registerTool( + "get_document", + { + title: "Get document", + description: + "Get a document by ID, including its status, source, and chunk/token/page counts.", + inputSchema: { + namespaceId: namespaceIdSchema, + documentId: documentIdSchema, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + + const doc = await getDocumentOrThrow({ + namespaceId: namespace.id, + documentId: args.documentId, + tenantId: ctx.tenantId, + }); + + return toDocumentResponse(doc); + }), + ); + + server.registerTool( + "delete_document", + { + title: "Delete document", + description: + "Delete a single document and its vectors. Deletion is asynchronous — the document status transitions to QUEUED_FOR_DELETE.", + inputSchema: { + namespaceId: namespaceIdSchema, + documentId: documentIdSchema, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + + const data = await queueDocumentDeletion({ + namespaceId: namespace.id, + organizationId: namespace.organizationId, + documentId: args.documentId, + tenantId: ctx.tenantId, + }); + + return toDocumentResponse(data); + }), + ); + + server.registerTool( + "get_document_chunks_download_url", + { + title: "Get document chunks download URL", + description: + "Get a presigned URL to download the parsed chunks of a document as JSON. Only available once the document status is COMPLETED. The URL is temporary — download it promptly.", + inputSchema: { + namespaceId: namespaceIdSchema, + documentId: documentIdSchema, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + + return getDocumentChunksDownloadUrl({ + namespaceId: namespace.id, + documentId: args.documentId, + tenantId: ctx.tenantId, + }); + }), + ); + + server.registerTool( + "get_document_file_download_url", + { + title: "Get document file download URL", + description: + "Get a presigned URL to download the original file of a document. Only available for documents ingested as managed files (uploaded via create_upload_urls). The URL is temporary — download it promptly.", + inputSchema: { + namespaceId: namespaceIdSchema, + documentId: documentIdSchema, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + + return getDocumentFileDownloadUrl({ + namespaceId: namespace.id, + documentId: args.documentId, + tenantId: ctx.tenantId, + }); + }), + ); +}; diff --git a/apps/web/src/lib/mcp/tools/hosting.ts b/apps/web/src/lib/mcp/tools/hosting.ts new file mode 100644 index 00000000..f78d9361 --- /dev/null +++ b/apps/web/src/lib/mcp/tools/hosting.ts @@ -0,0 +1,212 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; +import { namespaceIdSchema } from "@/lib/mcp/schemas"; +import { + addDomainSchema, + DomainSchema, + DomainStatusSchema, + HostingSchema, + updateHostingSchema, +} from "@/schemas/api/hosting"; +import { addDomain } from "@/services/domains/add"; +import { checkDomainStatus } from "@/services/domains/check-status"; +import { removeDomain } from "@/services/domains/remove"; +import { deleteHosting } from "@/services/hosting/delete"; +import { enableHosting } from "@/services/hosting/enable"; +import { getHosting } from "@/services/hosting/get"; +import { updateHosting } from "@/services/hosting/update"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { prefixId } from "@agentset/utils"; + +type HostingResult = NonNullable>>; + +const toHostingResponse = ( + hosting: Omit & { + domain?: HostingResult["domain"]; + }, +) => + HostingSchema.parse({ + ...hosting, + namespaceId: prefixId(hosting.namespaceId, "ns_"), + }); + +const getHostingOrThrow = async (namespaceId: string) => { + const hosting = await getHosting({ namespaceId }); + + if (!hosting) { + throw new AgentsetApiError({ + code: "not_found", + message: "Hosting is not enabled for this namespace.", + }); + } + + return hosting; +}; + +export const registerHostingTools = (server: McpServer) => { + server.registerTool( + "get_hosting", + { + title: "Get hosting", + description: + "Get the hosted chat/search interface configuration of a namespace, including its slug and custom domain (if any). Returns not_found when hosting has not been enabled — use enable_hosting first.", + inputSchema: { namespaceId: namespaceIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const hosting = await getHosting({ namespaceId: namespace.id }); + + if (!hosting) { + throw new AgentsetApiError({ + code: "not_found", + message: "Hosting not found for this namespace.", + }); + } + + return toHostingResponse(hosting); + }), + ); + + server.registerTool( + "enable_hosting", + { + title: "Enable hosting", + description: + "Enable a hosted chat/search interface for a namespace. A unique slug is generated automatically; customize the page afterwards with update_hosting and optionally attach a custom domain with set_custom_domain.", + inputSchema: { namespaceId: namespaceIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const hosting = await enableHosting({ namespaceId: namespace.id }); + + return toHostingResponse(hosting); + }), + ); + + server.registerTool( + "update_hosting", + { + title: "Update hosting", + description: + "Update the hosted interface of a namespace: title, slug, logo, Open Graph metadata, system prompt, example questions/search queries, welcome message, access protection (allowed emails/domains), search toggle, and retrieval settings (rerank model/limit, LLM model, topK). Hosting must be enabled first.", + inputSchema: { + namespaceId: namespaceIdSchema, + ...updateHostingSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const { namespaceId: _namespaceId, ...input } = args; + + const updatedHosting = await updateHosting({ + namespaceId: namespace.id, + input, + }); + + return toHostingResponse(updatedHosting); + }), + ); + + server.registerTool( + "disable_hosting", + { + title: "Disable hosting", + description: + "Disable and delete the hosted interface of a namespace, removing its custom domain (if any). The namespace and its documents are not affected.", + inputSchema: { namespaceId: namespaceIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + await deleteHosting({ namespaceId: namespace.id }); + + return { deleted: true }; + }), + ); + + server.registerTool( + "set_custom_domain", + { + title: "Set custom domain", + description: + "Attach a custom domain (e.g. docs.example.com) to the hosted interface of a namespace. Hosting must be enabled first, and only one domain can be set at a time. After setting it, call get_domain_status to retrieve the DNS records required for verification. Errors clearly if the deployment has no Vercel credentials configured.", + inputSchema: { + namespaceId: namespaceIdSchema, + ...addDomainSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const hosting = await getHostingOrThrow(namespace.id); + + const domain = await addDomain({ + hostingId: hosting.id, + domain: args.domain, + }); + + return DomainSchema.parse(domain); + }), + ); + + server.registerTool( + "get_domain_status", + { + title: "Get domain status", + description: + "Check the verification/configuration status of the custom domain attached to a namespace's hosted interface. When the status is 'Pending Verification', the response lists the DNS records to set; when there are conflicts, it lists the conflicting records to remove. Errors clearly if the deployment has no Vercel credentials configured.", + inputSchema: { namespaceId: namespaceIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const hosting = await getHostingOrThrow(namespace.id); + + if (!hosting.domain) { + throw new AgentsetApiError({ + code: "not_found", + message: "No custom domain is set for this namespace.", + }); + } + + const { status, response } = await checkDomainStatus({ + hostingId: hosting.id, + }); + + // the Vercel responses omit fields when the domain is not found, + // the schema defaults fill those in + return DomainStatusSchema.parse({ + domain: hosting.domain.slug, + status, + verified: status === "Valid Configuration", + apexName: response.domainJson.apexName, + verification: response.domainJson.verification, + conflicts: response.configJson.conflicts, + misconfigured: response.configJson.misconfigured, + }); + }), + ); + + server.registerTool( + "remove_custom_domain", + { + title: "Remove custom domain", + description: + "Detach the custom domain from a namespace's hosted interface. The hosted interface remains available on its Agentset slug. Errors clearly if the deployment has no Vercel credentials configured.", + inputSchema: { namespaceId: namespaceIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const hosting = await getHostingOrThrow(namespace.id); + + await removeDomain({ hostingId: hosting.id }); + + return { deleted: true }; + }), + ); +}; diff --git a/apps/web/src/lib/mcp/tools/ingestion.ts b/apps/web/src/lib/mcp/tools/ingestion.ts new file mode 100644 index 00000000..194d646d --- /dev/null +++ b/apps/web/src/lib/mcp/tools/ingestion.ts @@ -0,0 +1,252 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; +import { namespaceIdSchema } from "@/lib/mcp/schemas"; +import { + createIngestJobSchema, + IngestJobSchema, +} from "@/schemas/api/ingest-job"; +import { paginationSchema } from "@/schemas/api/pagination"; +import { batchUploadSchema, UploadResultSchema } from "@/schemas/api/upload"; +import { createIngestJob } from "@/services/ingest-jobs/create"; +import { deleteIngestJob } from "@/services/ingest-jobs/delete"; +import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; +import { getPaginationArgs, paginateResults } from "@/services/pagination"; +import { createBatchUpload } from "@/services/uploads"; +import { z } from "zod/v4"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { IngestJob } from "@agentset/db"; +import { IngestJobStatusSchema } from "@agentset/db"; +import { db } from "@agentset/db/client"; +import { isFreePlan } from "@agentset/stripe/plans"; +import { normalizeId, prefixId } from "@agentset/utils"; + +const jobIdSchema = z + .string() + .describe("The ID of the ingest job (e.g. `job_xxx`)."); + +const toIngestJobResponse = (job: IngestJob) => + IngestJobSchema.parse({ + ...job, + id: prefixId(job.id, "job_"), + namespaceId: prefixId(job.namespaceId, "ns_"), + }); + +const normalizeJobId = (rawJobId: string) => { + const jobId = normalizeId(rawJobId, "job_"); + if (!jobId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid job id", + }); + } + + return jobId; +}; + +export const registerIngestionTools = (server: McpServer) => { + server.registerTool( + "create_upload_urls", + { + title: "Create upload URLs", + description: + "Create presigned upload URLs for one or more files (two-step upload flow, step 1). For each file you get back a `url` and a `key`: make an HTTP PUT request to the `url` with the raw file bytes as the body and the same `Content-Type` header you declared here, then pass the `key` in a `MANAGED_FILE` payload to create_ingest_job (step 2). URLs expire, so upload promptly.", + inputSchema: { + namespaceId: namespaceIdSchema, + files: batchUploadSchema.shape.files.describe( + "The files to create upload URLs for (1-100).", + ), + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + + const result = await createBatchUpload({ + namespaceId: namespace.id, + files: args.files, + }); + + if (!result.success) { + throw new AgentsetApiError({ + code: "internal_server_error", + message: result.error, + }); + } + + return z.array(UploadResultSchema).parse(result.data); + }), + ); + + server.registerTool( + "create_ingest_job", + { + title: "Create ingest job", + description: + "Ingest content into a namespace so it becomes searchable. Payload variants: `TEXT` (raw text), `FILE` (a publicly accessible fileUrl), `MANAGED_FILE` (a key from create_upload_urls after PUT-ing the file), `CRAWL` (a website URL), `YOUTUBE` (a video URL), or `BATCH` (a list of FILE/MANAGED_FILE items). Ingestion is asynchronous: poll get_ingest_job until the status is COMPLETED before searching. If a tenant is set via the x-tenant-id header, the documents are partitioned to that tenant.", + inputSchema: { + namespaceId: namespaceIdSchema, + ...createIngestJobSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const { namespaceId: _namespaceId, ...body } = args; + + if (isFreePlan(ctx.organization.plan) && body.config?.mode === "accurate") { + throw new AgentsetApiError({ + code: "bad_request", + message: "Accurate mode requires a pro subscription.", + }); + } + + const job = await createIngestJob({ + data: body, + organization: { + id: ctx.organizationId, + plan: ctx.organization.plan, + totalPages: ctx.organization.totalPages, + pagesLimit: ctx.organization.pagesLimit, + }, + namespaceId: namespace.id, + tenantId: ctx.tenantId, + }); + + return toIngestJobResponse(job); + }), + ); + + server.registerTool( + "list_ingest_jobs", + { + title: "List ingest jobs", + description: + "List the ingest jobs of a namespace with cursor pagination. Pass the returned `pagination.nextCursor` as `cursor` to fetch the next page. Optionally filter by statuses.", + inputSchema: { + namespaceId: namespaceIdSchema, + statuses: z + .array(IngestJobStatusSchema) + .optional() + .describe("Statuses to filter by."), + orderBy: z.enum(["createdAt"]).optional().default("createdAt"), + order: z.enum(["asc", "desc"]).optional().default("desc"), + ...paginationSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + + const { where, ...paginationArgs } = getPaginationArgs( + args, + { + orderBy: args.orderBy, + order: args.order, + }, + "job_", + ); + + const ingestJobs = await db.ingestJob.findMany({ + where: { + namespaceId: namespace.id, + tenantId: ctx.tenantId, + ...(args.statuses && + args.statuses.length > 0 && { + status: { in: args.statuses }, + }), + ...where, + }, + ...paginationArgs, + }); + + const paginated = paginateResults( + args, + ingestJobs.map(toIngestJobResponse), + ); + + return { + records: paginated.records, + pagination: paginated.pagination, + }; + }), + ); + + server.registerTool( + "get_ingest_job", + { + title: "Get ingest job", + description: + "Get an ingest job by ID, including its status (QUEUED, PRE_PROCESSING, PROCESSING, COMPLETED, FAILED, ...) and error message if it failed. Poll this after create_ingest_job until the status is COMPLETED.", + inputSchema: { namespaceId: namespaceIdSchema, jobId: jobIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const jobId = normalizeJobId(args.jobId); + + const data = await db.ingestJob.findUnique({ + where: { + id: jobId, + namespaceId: namespace.id, + }, + }); + + if (!data) { + throw new AgentsetApiError({ + code: "not_found", + message: "Ingest job not found", + }); + } + + return toIngestJobResponse(data); + }), + ); + + server.registerTool( + "delete_ingest_job", + { + title: "Delete ingest job", + description: + "Delete an ingest job and all documents it created. Deletion is asynchronous — the job status transitions to QUEUED_FOR_DELETE.", + inputSchema: { namespaceId: namespaceIdSchema, jobId: jobIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const jobId = normalizeJobId(args.jobId); + + const data = await deleteIngestJob({ + jobId, + namespaceId: namespace.id, + organizationId: namespace.organizationId, + }); + + return toIngestJobResponse(data); + }), + ); + + server.registerTool( + "reingest_job", + { + title: "Re-ingest job", + description: + "Re-run an existing ingest job (e.g. after a failure or to refresh crawled content). The job must not currently be processing or deleting. Poll get_ingest_job for the new status.", + inputSchema: { namespaceId: namespaceIdSchema, jobId: jobIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const jobId = normalizeJobId(args.jobId); + + const job = await reIngestJob({ + jobId, + namespaceId: namespace.id, + organizationId: namespace.organizationId, + plan: ctx.organization.plan, + }); + + return { id: prefixId(job.id, "job_") }; + }), + ); +}; diff --git a/apps/web/src/lib/mcp/tools/namespaces.ts b/apps/web/src/lib/mcp/tools/namespaces.ts new file mode 100644 index 00000000..20508be6 --- /dev/null +++ b/apps/web/src/lib/mcp/tools/namespaces.ts @@ -0,0 +1,125 @@ +import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; +import { namespaceIdSchema } from "@/lib/mcp/schemas"; +import { + createNamespaceSchema, + NamespaceSchema, + updateNamespaceSchema, +} from "@/schemas/api/namespace"; +import { createNamespace } from "@/services/namespaces/create"; +import { deleteNamespace } from "@/services/namespaces/delete"; +import { updateNamespace } from "@/services/namespaces/update"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Namespace } from "@agentset/db"; +import { NamespaceStatus } from "@agentset/db"; +import { db } from "@agentset/db/client"; +import { prefixId } from "@agentset/utils"; + +const toNamespaceResponse = (namespace: Namespace) => + NamespaceSchema.parse({ + ...namespace, + id: prefixId(namespace.id, "ns_"), + organizationId: prefixId(namespace.organizationId, "org_"), + }); + +export const registerNamespaceTools = (server: McpServer) => { + server.registerTool( + "list_namespaces", + { + title: "List namespaces", + description: + "List all active namespaces in the organization. A namespace is an isolated RAG index (documents + vector store). Most other tools require a namespaceId from this list.", + }, + async (extra) => + runTool(extra, async (ctx) => { + const namespaces = await db.namespace.findMany({ + where: { + organizationId: ctx.organizationId, + status: NamespaceStatus.ACTIVE, + }, + orderBy: { + createdAt: "asc", + }, + }); + + return namespaces.map(toNamespaceResponse); + }), + ); + + server.registerTool( + "get_namespace", + { + title: "Get namespace", + description: + "Get a namespace by ID, including its embedding and vector store configuration.", + inputSchema: { namespaceId: namespaceIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + return toNamespaceResponse(namespace); + }), + ); + + server.registerTool( + "create_namespace", + { + title: "Create namespace", + description: + "Create a new namespace. Omit embeddingConfig and vectorStoreConfig to use Agentset's managed defaults (recommended); if provided, they cannot be changed after creation. After creating a namespace, upload files with create_upload_urls and ingest them with create_ingest_job.", + inputSchema: createNamespaceSchema.shape, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await createNamespace({ + organizationId: ctx.organizationId, + data: args, + }); + + return toNamespaceResponse(namespace); + }), + ); + + server.registerTool( + "update_namespace", + { + title: "Update namespace", + description: + "Update a namespace's name and/or slug. At least one field must be provided. Embedding and vector store configs cannot be changed.", + inputSchema: { + namespaceId: namespaceIdSchema, + ...updateNamespaceSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + const { namespaceId: _namespaceId, ...data } = args; + + const updatedNamespace = await updateNamespace({ + namespaceId: namespace.id, + organizationId: namespace.organizationId, + data, + }); + + return toNamespaceResponse(updatedNamespace); + }), + ); + + server.registerTool( + "delete_namespace", + { + title: "Delete namespace", + description: + "Delete a namespace and all of its documents and vectors. This is irreversible and processed asynchronously — the namespace immediately stops appearing in list_namespaces.", + inputSchema: { namespaceId: namespaceIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const namespace = await resolveNamespace(ctx, args.namespaceId); + await deleteNamespace({ namespaceId: namespace.id }); + + return { deleted: true, id: prefixId(namespace.id, "ns_") }; + }), + ); +}; diff --git a/apps/web/src/lib/mcp/tools/organization.ts b/apps/web/src/lib/mcp/tools/organization.ts new file mode 100644 index 00000000..aaf1f1f2 --- /dev/null +++ b/apps/web/src/lib/mcp/tools/organization.ts @@ -0,0 +1,92 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { runTool } from "@/lib/mcp/run-tool"; +import { + OrganizationMembersSchema, + updateOrganizationSchema, +} from "@/schemas/api/organization"; +import { + getOrganization, + toOrganizationResponse, +} from "@/services/organizations/get"; +import { getOrganizationMembers } from "@/services/organizations/members"; +import { updateOrganization } from "@/services/organizations/update"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export const registerOrganizationTools = (server: McpServer) => { + server.registerTool( + "get_organization", + { + title: "Get organization", + description: + "Get the organization that owns the current API key, including its plan, search usage/limits, page usage/limits, and API rate limit. Use this to check remaining quota before running searches or ingesting documents.", + }, + async (extra) => + runTool(extra, async (ctx) => { + const org = await getOrganization({ + organizationId: ctx.organizationId, + }); + + if (!org) { + throw new AgentsetApiError({ + code: "not_found", + message: "Organization not found.", + }); + } + + return toOrganizationResponse(org); + }), + ); + + server.registerTool( + "update_organization", + { + title: "Update organization", + description: + "Update the organization's name and/or slug. At least one field must be provided. The slug must be unique across Agentset.", + inputSchema: updateOrganizationSchema.shape, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + // the schema-level refine doesn't survive .shape spreading + if (args.name === undefined && args.slug === undefined) { + throw new AgentsetApiError({ + code: "bad_request", + message: "At least one field must be provided", + }); + } + + const updatedOrganization = await updateOrganization({ + organizationId: ctx.organizationId, + name: args.name, + slug: args.slug, + }); + + return toOrganizationResponse(updatedOrganization); + }), + ); + + server.registerTool( + "list_members", + { + title: "List organization members", + description: + "List the members of the organization and its pending invitations.", + }, + async (extra) => + runTool(extra, async (ctx) => { + const members = await getOrganizationMembers({ + organizationId: ctx.organizationId, + }); + + if (!members) { + throw new AgentsetApiError({ + code: "not_found", + message: "Organization not found.", + }); + } + + return OrganizationMembersSchema.parse(members); + }), + ); +}; diff --git a/apps/web/src/lib/mcp/tools/search.ts b/apps/web/src/lib/mcp/tools/search.ts new file mode 100644 index 00000000..213b2e0a --- /dev/null +++ b/apps/web/src/lib/mcp/tools/search.ts @@ -0,0 +1,48 @@ +import { + checkSearchLimit, + incrementOrganizationSearchUsage, +} from "@/lib/api/usage"; +import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; +import { namespaceIdSchema } from "@/lib/mcp/schemas"; +import { + baseQueryVectorStoreSchema, + queryVectorStoreSchema, +} from "@/schemas/api/query"; +import { searchNamespace } from "@/services/search"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export const registerSearchTools = (server: McpServer) => { + server.registerTool( + "search", + { + title: "Search a namespace", + description: + "Run a semantic (or keyword) search over the documents of a namespace and get back the most relevant chunks. Documents must be ingested (create_ingest_job) and COMPLETED first. Counts toward the organization's search quota. If a tenant is set via the x-tenant-id header, only that tenant's documents are searched.", + inputSchema: { + namespaceId: namespaceIdSchema, + ...baseQueryVectorStoreSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + checkSearchLimit(ctx.organization); + + const namespace = await resolveNamespace(ctx, args.namespaceId); + const { namespaceId: _namespaceId, ...rest } = args; + + // re-parse to apply the same cross-field validation as the REST route + const body = await queryVectorStoreSchema.parseAsync(rest); + + const results = await searchNamespace({ + namespace, + tenantId: ctx.tenantId, + options: body, + }); + + incrementOrganizationSearchUsage(ctx.organizationId, 1); + + return results; + }), + ); +}; diff --git a/apps/web/src/lib/mcp/tools/webhooks.ts b/apps/web/src/lib/mcp/tools/webhooks.ts new file mode 100644 index 00000000..197f46d8 --- /dev/null +++ b/apps/web/src/lib/mcp/tools/webhooks.ts @@ -0,0 +1,179 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { runTool } from "@/lib/mcp/run-tool"; +import { + createWebhookSchema, + updateWebhookSchema, + WebhookDetailsSchema, + WebhookSchema, + WebhookSummarySchema, +} from "@/schemas/api/webhook"; +import { createWebhook } from "@/services/webhooks/create"; +import { deleteWebhook } from "@/services/webhooks/delete"; +import { getWebhook } from "@/services/webhooks/get"; +import { listWebhooks } from "@/services/webhooks/list"; +import { requireWebhooksPlan } from "@/services/webhooks/plan"; +import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; +import { applyWebhookUpdate } from "@/services/webhooks/update"; +import { z } from "zod/v4"; + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { WebhookProps } from "@agentset/webhooks"; +import { normalizeId, prefixId } from "@agentset/utils"; + +const webhookIdSchema = z + .string() + .describe( + "The ID of the webhook to operate on (e.g. `wh_xxx`). Use list_webhooks to find it.", + ); + +const webhookNotFoundError = () => + new AgentsetApiError({ + code: "not_found", + message: "Webhook not found.", + }); + +// webhook IDs are stored with the wh_ prefix; only namespace IDs need prefixing +const prefixNamespaceIds = >( + webhook: T, +) => ({ + ...webhook, + namespaceIds: (webhook.namespaceIds ?? []).map((id) => prefixId(id, "ns_")), +}); + +export const registerWebhookTools = (server: McpServer) => { + server.registerTool( + "list_webhooks", + { + title: "List webhooks", + description: + "List the organization's webhooks (name, URL, triggers, enabled state, and namespace scope). Signing secrets are not included — use get_webhook to retrieve one.", + }, + async (extra) => + runTool(extra, async (ctx) => { + const webhooks = await listWebhooks({ + organizationId: ctx.organizationId, + }); + + return webhooks.map((webhook) => + WebhookSummarySchema.parse(prefixNamespaceIds(webhook)), + ); + }), + ); + + server.registerTool( + "get_webhook", + { + title: "Get webhook", + description: + "Get a webhook by its ID, including its signing secret (`whsec_...`), delivery health (consecutive failures, last failure time), and creation date. Use list_webhooks to find the ID.", + inputSchema: { webhookId: webhookIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const webhook = await getWebhook({ + organizationId: ctx.organizationId, + webhookId: args.webhookId, + }); + + if (!webhook) { + throw webhookNotFoundError(); + } + + return WebhookDetailsSchema.parse(prefixNamespaceIds(webhook)); + }), + ); + + server.registerTool( + "create_webhook", + { + title: "Create webhook", + description: + "Create a webhook that receives document and ingest-job lifecycle events via HTTP POST. Scope it to specific namespaces with namespaceIds, or omit them to receive events for the whole organization. The response includes the signing secret. Requires the Pro plan or above.", + inputSchema: createWebhookSchema.shape, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + requireWebhooksPlan(ctx.organization.plan); + + const webhook = await createWebhook({ + ...args, + namespaceIds: args.namespaceIds?.map((id) => normalizeId(id, "ns_")), + organizationId: ctx.organizationId, + }); + + return WebhookSchema.parse(prefixNamespaceIds(webhook)); + }), + ); + + server.registerTool( + "update_webhook", + { + title: "Update webhook", + description: + "Update a webhook's name, URL, triggers, or namespace scope, and/or enable/disable it. At least one field must be provided. Field updates require the Pro plan or above; toggling `enabled` alone does not. Re-enabling a disabled webhook resets its failure count.", + inputSchema: { + webhookId: webhookIdSchema, + ...updateWebhookSchema.shape, + }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const { webhookId, ...body } = args; + + const webhook = await applyWebhookUpdate({ + organizationId: ctx.organizationId, + plan: ctx.organization.plan, + webhookId, + ...body, + }); + + return WebhookSchema.parse(prefixNamespaceIds(webhook)); + }), + ); + + server.registerTool( + "delete_webhook", + { + title: "Delete webhook", + description: + "Permanently delete a webhook by its ID. Events stop being delivered immediately. Use list_webhooks to find the ID.", + inputSchema: { webhookId: webhookIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const webhook = await deleteWebhook({ + organizationId: ctx.organizationId, + webhookId: args.webhookId, + }); + + if (!webhook) { + throw webhookNotFoundError(); + } + + return { deleted: true, id: webhook.id }; + }), + ); + + server.registerTool( + "regenerate_webhook_secret", + { + title: "Regenerate webhook secret", + description: + "Generate a new signing secret for a webhook, invalidating the old one immediately. The response includes the new secret (`whsec_...`) — update the receiving endpoint's signature verification before deliveries fail.", + inputSchema: { webhookId: webhookIdSchema }, + }, + async (args, extra) => + runTool(extra, async (ctx) => { + const webhook = await regenerateWebhookSecret({ + organizationId: ctx.organizationId, + webhookId: args.webhookId, + }); + + if (!webhook) { + throw webhookNotFoundError(); + } + + return WebhookSchema.parse(prefixNamespaceIds(webhook)); + }), + ); +}; diff --git a/apps/web/src/openapi/v1/api-keys/create-api-key.ts b/apps/web/src/openapi/v1/api-keys/create-api-key.ts new file mode 100644 index 00000000..c17b4669 --- /dev/null +++ b/apps/web/src/openapi/v1/api-keys/create-api-key.ts @@ -0,0 +1,44 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { + createApiKeyBodySchema, + CreatedApiKeySchema, +} from "@/schemas/api/api-key"; + +import { makeCodeSamples, ts } from "../code-samples"; + +export const createApiKey: ZodOpenApiOperationObject = { + operationId: "createApiKey", + "x-speakeasy-name-override": "create", + summary: "Create an API key", + description: + "Create an API key for the authenticated organization. The full key is only returned once, at creation time.", + requestBody: { + required: true, + content: { + "application/json": { schema: createApiKeyBodySchema }, + }, + }, + responses: { + "201": { + description: "The created API key", + content: { + "application/json": { + schema: successSchema(CreatedApiKeySchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["API Keys"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const apiKey = await agentset.apiKeys.create({ + label: "My API Key", +}); +console.log(apiKey.key); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/api-keys/delete-api-key.ts b/apps/web/src/openapi/v1/api-keys/delete-api-key.ts new file mode 100644 index 00000000..5aebe8e4 --- /dev/null +++ b/apps/web/src/openapi/v1/api-keys/delete-api-key.ts @@ -0,0 +1,40 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses } from "@/openapi/responses"; +import { z } from "zod/v4"; + +import { makeCodeSamples, ts } from "../code-samples"; + +const keyIdPathSchema = z.string().meta({ + examples: ["cm4x1q2z90000abcd1234efgh"], + description: "The id of the API key.", + param: { + in: "path", + name: "keyId", + id: "ApiKeyIdRef", + }, +}); + +export const deleteApiKey: ZodOpenApiOperationObject = { + operationId: "deleteApiKey", + "x-speakeasy-name-override": "delete", + "x-speakeasy-max-method-params": 1, + summary: "Delete an API key", + description: + "Delete an API key for the authenticated organization. The key is revoked immediately.", + parameters: [keyIdPathSchema], + responses: { + "204": { + description: "The API key was deleted", + }, + ...openApiErrorResponses, + }, + tags: ["API Keys"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +await agentset.apiKeys.delete("cm4x1q2z90000abcd1234efgh"); +console.log("API key deleted"); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/api-keys/index.ts b/apps/web/src/openapi/v1/api-keys/index.ts new file mode 100644 index 00000000..46de834a --- /dev/null +++ b/apps/web/src/openapi/v1/api-keys/index.ts @@ -0,0 +1,15 @@ +import type { ZodOpenApiPathsObject } from "zod-openapi"; + +import { createApiKey } from "./create-api-key"; +import { deleteApiKey } from "./delete-api-key"; +import { listApiKeys } from "./list-api-keys"; + +export const apiKeysPaths: ZodOpenApiPathsObject = { + "/v1/api-keys": { + get: listApiKeys, + post: createApiKey, + }, + "/v1/api-keys/{keyId}": { + delete: deleteApiKey, + }, +}; diff --git a/apps/web/src/openapi/v1/api-keys/list-api-keys.ts b/apps/web/src/openapi/v1/api-keys/list-api-keys.ts new file mode 100644 index 00000000..fcd57bfe --- /dev/null +++ b/apps/web/src/openapi/v1/api-keys/list-api-keys.ts @@ -0,0 +1,34 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { ApiKeySchema } from "@/schemas/api/api-key"; +import { z } from "zod/v4"; + +import { makeCodeSamples, ts } from "../code-samples"; + +export const listApiKeys: ZodOpenApiOperationObject = { + operationId: "listApiKeys", + "x-speakeasy-name-override": "list", + summary: "Retrieve a list of API keys", + description: + "Retrieve a list of API keys for the authenticated organization. The key material is never returned.", + responses: { + "200": { + description: "The retrieved API keys", + content: { + "application/json": { + schema: successSchema(z.array(ApiKeySchema)), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["API Keys"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const apiKeys = await agentset.apiKeys.list(); +console.log(apiKeys); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/chat.ts b/apps/web/src/openapi/v1/chat.ts new file mode 100644 index 00000000..02ab5807 --- /dev/null +++ b/apps/web/src/openapi/v1/chat.ts @@ -0,0 +1,54 @@ +import type { + ZodOpenApiOperationObject, + ZodOpenApiPathsObject, +} from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { chatResponseSchema, chatSchema } from "@/schemas/api/chat"; + +import { makeCodeSamples, ts } from "./code-samples"; +import { namespaceIdPathSchema, tenantHeaderSchema } from "./utils"; + +export const chat: ZodOpenApiOperationObject = { + operationId: "chat", + "x-speakeasy-name-override": "execute", + "x-speakeasy-group": "chat", + summary: "Chat with a namespace", + description: + "Generate an answer to a conversation using the documents in a namespace (RAG). Supports `normal`, `agentic`, and `deepResearch` modes. When `stream` is `true`, the response is a `text/event-stream` of AI SDK UI message parts (including a `data-agentset-sources` part with the retrieved chunks) instead of the JSON envelope documented below.", + parameters: [namespaceIdPathSchema, tenantHeaderSchema], + requestBody: { + required: true, + content: { + "application/json": { + schema: chatSchema, + }, + }, + }, + responses: { + "200": { + description: "The generated answer and the sources used to generate it", + content: { + "application/json": { + schema: successSchema(chatResponseSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Chat"], + security: [{ token: [] }], + ...makeCodeSamples(ts` +const result = await ns.chat({ + messages: [{ role: "user", content: "What is machine learning?" }], + topK: 20, + rerank: true, +}); +console.log(result.message.content); +`), +}; + +export const chatPaths: ZodOpenApiPathsObject = { + "/v1/namespace/{namespaceId}/chat": { + post: chat, + }, +}; diff --git a/apps/web/src/openapi/v1/hosting/add-domain.ts b/apps/web/src/openapi/v1/hosting/add-domain.ts new file mode 100644 index 00000000..8dffa01c --- /dev/null +++ b/apps/web/src/openapi/v1/hosting/add-domain.ts @@ -0,0 +1,41 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { addDomainSchema, DomainSchema } from "@/schemas/api/hosting"; + +import { makeCodeSamples, ts } from "../code-samples"; +import { namespaceIdPathSchema } from "../utils"; + +export const addDomain: ZodOpenApiOperationObject = { + operationId: "addDomain", + "x-speakeasy-name-override": "addDomain", + "x-speakeasy-max-method-params": 1, + summary: "Add a custom domain", + description: + "Attach a custom domain to the hosting configuration of a namespace. Only one domain can be attached at a time.", + parameters: [namespaceIdPathSchema], + requestBody: { + required: true, + content: { + "application/json": { schema: addDomainSchema }, + }, + }, + responses: { + "201": { + description: "The added domain", + content: { + "application/json": { + schema: successSchema(DomainSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Hosting"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const domain = await ns.hosting.addDomain({ domain: "docs.example.com" }); +console.log(domain); +`, + ), +}; diff --git a/apps/web/src/openapi/v1/hosting/check-domain-status.ts b/apps/web/src/openapi/v1/hosting/check-domain-status.ts new file mode 100644 index 00000000..2278171b --- /dev/null +++ b/apps/web/src/openapi/v1/hosting/check-domain-status.ts @@ -0,0 +1,34 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { DomainStatusSchema } from "@/schemas/api/hosting"; + +import { makeCodeSamples, ts } from "../code-samples"; +import { namespaceIdPathSchema } from "../utils"; + +export const checkDomainStatus: ZodOpenApiOperationObject = { + operationId: "checkDomainStatus", + "x-speakeasy-name-override": "checkDomainStatus", + summary: "Retrieve custom domain status", + description: + "Retrieve the DNS configuration status of the custom domain attached to a namespace's hosting. If the domain is pending verification, a verification attempt is made automatically.", + parameters: [namespaceIdPathSchema], + responses: { + "200": { + description: "The domain status", + content: { + "application/json": { + schema: successSchema(DomainStatusSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Hosting"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const status = await ns.hosting.checkDomainStatus(); +console.log(status); +`, + ), +}; diff --git a/apps/web/src/openapi/v1/hosting/delete-hosting.ts b/apps/web/src/openapi/v1/hosting/delete-hosting.ts index 3653d5cf..1590e026 100644 --- a/apps/web/src/openapi/v1/hosting/delete-hosting.ts +++ b/apps/web/src/openapi/v1/hosting/delete-hosting.ts @@ -1,6 +1,5 @@ import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { HostingSchema } from "@/schemas/api/hosting"; +import { openApiErrorResponses } from "@/openapi/responses"; import { makeCodeSamples, ts } from "../code-samples"; import { namespaceIdPathSchema } from "../utils"; @@ -9,16 +8,12 @@ export const deleteHosting: ZodOpenApiOperationObject = { operationId: "deleteHosting", "x-speakeasy-name-override": "delete", summary: "Delete hosting configuration", - description: "Delete the hosting configuration for a namespace.", + description: + "Delete the hosting configuration for a namespace. Also removes the attached custom domain, if any.", parameters: [namespaceIdPathSchema], responses: { "204": { - description: "The deleted hosting configuration", - content: { - "application/json": { - schema: successSchema(HostingSchema), - }, - }, + description: "The hosting configuration was deleted", }, ...openApiErrorResponses, }, diff --git a/apps/web/src/openapi/v1/hosting/index.ts b/apps/web/src/openapi/v1/hosting/index.ts index e05f088b..54151e58 100644 --- a/apps/web/src/openapi/v1/hosting/index.ts +++ b/apps/web/src/openapi/v1/hosting/index.ts @@ -1,8 +1,11 @@ import type { ZodOpenApiPathsObject } from "zod-openapi"; +import { addDomain } from "./add-domain"; +import { checkDomainStatus } from "./check-domain-status"; import { deleteHosting } from "./delete-hosting"; import { enableHosting } from "./enable-hosting"; import { getHosting } from "./get-hosting"; +import { removeDomain } from "./remove-domain"; import { updateHosting } from "./update-hosting"; export const hostingPaths: ZodOpenApiPathsObject = { @@ -12,4 +15,9 @@ export const hostingPaths: ZodOpenApiPathsObject = { patch: updateHosting, delete: deleteHosting, }, + "/v1/namespace/{namespaceId}/hosting/domain": { + get: checkDomainStatus, + post: addDomain, + delete: removeDomain, + }, }; diff --git a/apps/web/src/openapi/v1/hosting/remove-domain.ts b/apps/web/src/openapi/v1/hosting/remove-domain.ts new file mode 100644 index 00000000..b02103c6 --- /dev/null +++ b/apps/web/src/openapi/v1/hosting/remove-domain.ts @@ -0,0 +1,28 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses } from "@/openapi/responses"; + +import { makeCodeSamples, ts } from "../code-samples"; +import { namespaceIdPathSchema } from "../utils"; + +export const removeDomain: ZodOpenApiOperationObject = { + operationId: "removeDomain", + "x-speakeasy-name-override": "removeDomain", + summary: "Remove the custom domain", + description: + "Remove the custom domain attached to the hosting configuration of a namespace.", + parameters: [namespaceIdPathSchema], + responses: { + "204": { + description: "The domain was removed", + }, + ...openApiErrorResponses, + }, + tags: ["Hosting"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +await ns.hosting.removeDomain(); +console.log("Domain removed"); +`, + ), +}; diff --git a/apps/web/src/openapi/v1/index.ts b/apps/web/src/openapi/v1/index.ts index d69b9c56..e6ee0e70 100644 --- a/apps/web/src/openapi/v1/index.ts +++ b/apps/web/src/openapi/v1/index.ts @@ -1,19 +1,27 @@ import type { ZodOpenApiPathsObject } from "zod-openapi"; +import { apiKeysPaths } from "./api-keys"; +import { chatPaths } from "./chat"; import { documentsPaths } from "./documents"; import { hostingPaths } from "./hosting"; import { ingestJobsPaths } from "./ingest-jobs"; import { namespacesPaths } from "./namespaces"; +import { organizationPaths } from "./organization"; import { searchPaths } from "./search"; import { uploadsPaths } from "./uploads"; import { warmUpPaths } from "./warm-up"; +import { webhooksPaths } from "./webhooks"; export const v1Paths: ZodOpenApiPathsObject = { ...namespacesPaths, ...ingestJobsPaths, ...documentsPaths, ...searchPaths, + ...chatPaths, ...uploadsPaths, ...hostingPaths, ...warmUpPaths, + ...webhooksPaths, + ...organizationPaths, + ...apiKeysPaths, }; diff --git a/apps/web/src/openapi/v1/namespaces/delete-namespace.ts b/apps/web/src/openapi/v1/namespaces/delete-namespace.ts index 8fab0d23..f90c2c72 100644 --- a/apps/web/src/openapi/v1/namespaces/delete-namespace.ts +++ b/apps/web/src/openapi/v1/namespaces/delete-namespace.ts @@ -1,6 +1,5 @@ import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { NamespaceSchema } from "@/schemas/api/namespace"; +import { openApiErrorResponses } from "@/openapi/responses"; import { makeCodeSamples, ts } from "../code-samples"; import { namespaceIdPathSchema } from "../utils"; @@ -15,12 +14,7 @@ export const deleteNamespace: ZodOpenApiOperationObject = { parameters: [namespaceIdPathSchema], responses: { "204": { - description: "The deleted namespace", - content: { - "application/json": { - schema: successSchema(NamespaceSchema), - }, - }, + description: "The namespace was queued for deletion.", }, ...openApiErrorResponses, }, diff --git a/apps/web/src/openapi/v1/organization/get-organization.ts b/apps/web/src/openapi/v1/organization/get-organization.ts new file mode 100644 index 00000000..d85e0edd --- /dev/null +++ b/apps/web/src/openapi/v1/organization/get-organization.ts @@ -0,0 +1,33 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { OrganizationSchema } from "@/schemas/api/organization"; + +import { makeCodeSamples, ts } from "../code-samples"; + +export const getOrganization: ZodOpenApiOperationObject = { + operationId: "getOrganization", + "x-speakeasy-name-override": "get", + summary: "Retrieve the organization", + description: + "Retrieve the organization associated with the API key, including its usage and limits.", + responses: { + "200": { + description: "The retrieved organization", + content: { + "application/json": { + schema: successSchema(OrganizationSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Organization"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const organization = await agentset.organization.get(); +console.log(organization); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/organization/index.ts b/apps/web/src/openapi/v1/organization/index.ts new file mode 100644 index 00000000..d2e573e7 --- /dev/null +++ b/apps/web/src/openapi/v1/organization/index.ts @@ -0,0 +1,15 @@ +import type { ZodOpenApiPathsObject } from "zod-openapi"; + +import { getOrganization } from "./get-organization"; +import { listOrganizationMembers } from "./list-members"; +import { updateOrganization } from "./update-organization"; + +export const organizationPaths: ZodOpenApiPathsObject = { + "/v1/organization": { + get: getOrganization, + patch: updateOrganization, + }, + "/v1/organization/members": { + get: listOrganizationMembers, + }, +}; diff --git a/apps/web/src/openapi/v1/organization/list-members.ts b/apps/web/src/openapi/v1/organization/list-members.ts new file mode 100644 index 00000000..68f79a8a --- /dev/null +++ b/apps/web/src/openapi/v1/organization/list-members.ts @@ -0,0 +1,33 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { OrganizationMembersSchema } from "@/schemas/api/organization"; + +import { makeCodeSamples, ts } from "../code-samples"; + +export const listOrganizationMembers: ZodOpenApiOperationObject = { + operationId: "listOrganizationMembers", + "x-speakeasy-name-override": "members", + summary: "Retrieve the organization members", + description: + "Retrieve the members and pending invitations of the organization associated with the API key.", + responses: { + "200": { + description: "The retrieved members and pending invitations", + content: { + "application/json": { + schema: successSchema(OrganizationMembersSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Organization"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const members = await agentset.organization.members(); +console.log(members); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/organization/update-organization.ts b/apps/web/src/openapi/v1/organization/update-organization.ts new file mode 100644 index 00000000..48f944d4 --- /dev/null +++ b/apps/web/src/openapi/v1/organization/update-organization.ts @@ -0,0 +1,44 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { + OrganizationSchema, + updateOrganizationSchema, +} from "@/schemas/api/organization"; + +import { makeCodeSamples, ts } from "../code-samples"; + +export const updateOrganization: ZodOpenApiOperationObject = { + operationId: "updateOrganization", + "x-speakeasy-name-override": "update", + summary: "Update the organization", + description: + "Update the name and/or slug of the organization associated with the API key.", + requestBody: { + required: true, + content: { + "application/json": { schema: updateOrganizationSchema }, + }, + }, + responses: { + "200": { + description: "The updated organization", + content: { + "application/json": { + schema: successSchema(OrganizationSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Organization"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const organization = await agentset.organization.update({ + name: "My Organization", +}); +console.log(organization); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/webhooks/create-webhook.ts b/apps/web/src/openapi/v1/webhooks/create-webhook.ts new file mode 100644 index 00000000..e97464b0 --- /dev/null +++ b/apps/web/src/openapi/v1/webhooks/create-webhook.ts @@ -0,0 +1,45 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { createWebhookSchema, WebhookSchema } from "@/schemas/api/webhook"; + +import { makeCodeSamples, ts } from "../code-samples"; + +export const createWebhook: ZodOpenApiOperationObject = { + operationId: "createWebhook", + "x-speakeasy-name-override": "create", + "x-speakeasy-usage-example": true, + summary: "Create a webhook.", + description: + "Create a webhook for the authenticated organization. Requires the Pro plan or above.", + requestBody: { + required: true, + content: { + "application/json": { schema: createWebhookSchema }, + }, + }, + responses: { + "201": { + description: "The created webhook", + content: { + "application/json": { + schema: successSchema(WebhookSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Webhooks"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const webhook = await agentset.webhooks.create({ + name: "My Webhook", + url: "https://example.com/webhooks/agentset", + triggers: ["document.ready", "ingest_job.ready"], + // namespaceIds: ["ns_xxx"], +}); +console.log(webhook); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/webhooks/delete-webhook.ts b/apps/web/src/openapi/v1/webhooks/delete-webhook.ts new file mode 100644 index 00000000..4618f63a --- /dev/null +++ b/apps/web/src/openapi/v1/webhooks/delete-webhook.ts @@ -0,0 +1,29 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses } from "@/openapi/responses"; + +import { makeCodeSamples, ts } from "../code-samples"; +import { webhookIdPathSchema } from "./utils"; + +export const deleteWebhook: ZodOpenApiOperationObject = { + operationId: "deleteWebhook", + "x-speakeasy-name-override": "delete", + "x-speakeasy-max-method-params": 1, + summary: "Delete a webhook.", + description: "Delete a webhook for the authenticated organization.", + parameters: [webhookIdPathSchema], + responses: { + "204": { + description: "The webhook was deleted", + }, + ...openApiErrorResponses, + }, + tags: ["Webhooks"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +await agentset.webhooks.delete("wh_xxx"); +console.log("Webhook deleted"); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/webhooks/get-webhook.ts b/apps/web/src/openapi/v1/webhooks/get-webhook.ts new file mode 100644 index 00000000..4d5c1c12 --- /dev/null +++ b/apps/web/src/openapi/v1/webhooks/get-webhook.ts @@ -0,0 +1,35 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { WebhookDetailsSchema } from "@/schemas/api/webhook"; + +import { makeCodeSamples, ts } from "../code-samples"; +import { webhookIdPathSchema } from "./utils"; + +export const getWebhook: ZodOpenApiOperationObject = { + operationId: "getWebhook", + "x-speakeasy-name-override": "get", + summary: "Retrieve a webhook", + description: + "Retrieve the info for a webhook, including its signing secret and delivery failure stats.", + parameters: [webhookIdPathSchema], + responses: { + "200": { + description: "The retrieved webhook", + content: { + "application/json": { + schema: successSchema(WebhookDetailsSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Webhooks"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const webhook = await agentset.webhooks.get("wh_xxx"); +console.log(webhook); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/webhooks/index.ts b/apps/web/src/openapi/v1/webhooks/index.ts new file mode 100644 index 00000000..ae3a9987 --- /dev/null +++ b/apps/web/src/openapi/v1/webhooks/index.ts @@ -0,0 +1,23 @@ +import type { ZodOpenApiPathsObject } from "zod-openapi"; + +import { createWebhook } from "./create-webhook"; +import { deleteWebhook } from "./delete-webhook"; +import { getWebhook } from "./get-webhook"; +import { listWebhooks } from "./list-webhooks"; +import { regenerateWebhookSecret } from "./regenerate-webhook-secret"; +import { updateWebhook } from "./update-webhook"; + +export const webhooksPaths: ZodOpenApiPathsObject = { + "/v1/webhooks": { + get: listWebhooks, + post: createWebhook, + }, + "/v1/webhooks/{webhookId}": { + get: getWebhook, + patch: updateWebhook, + delete: deleteWebhook, + }, + "/v1/webhooks/{webhookId}/regenerate-secret": { + post: regenerateWebhookSecret, + }, +}; diff --git a/apps/web/src/openapi/v1/webhooks/list-webhooks.ts b/apps/web/src/openapi/v1/webhooks/list-webhooks.ts new file mode 100644 index 00000000..009721e2 --- /dev/null +++ b/apps/web/src/openapi/v1/webhooks/list-webhooks.ts @@ -0,0 +1,34 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { WebhookSummarySchema } from "@/schemas/api/webhook"; +import { z } from "zod/v4"; + +import { makeCodeSamples, ts } from "../code-samples"; + +export const listWebhooks: ZodOpenApiOperationObject = { + operationId: "listWebhooks", + "x-speakeasy-name-override": "list", + summary: "Retrieve a list of webhooks", + description: + "Retrieve a list of webhooks for the authenticated organization. The signing secret is not included, retrieve a single webhook to get it.", + responses: { + "200": { + description: "The retrieved webhooks", + content: { + "application/json": { + schema: successSchema(z.array(WebhookSummarySchema)), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Webhooks"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const webhooks = await agentset.webhooks.list(); +console.log(webhooks); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/webhooks/regenerate-webhook-secret.ts b/apps/web/src/openapi/v1/webhooks/regenerate-webhook-secret.ts new file mode 100644 index 00000000..f1a87666 --- /dev/null +++ b/apps/web/src/openapi/v1/webhooks/regenerate-webhook-secret.ts @@ -0,0 +1,36 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { WebhookSchema } from "@/schemas/api/webhook"; + +import { makeCodeSamples, ts } from "../code-samples"; +import { webhookIdPathSchema } from "./utils"; + +export const regenerateWebhookSecret: ZodOpenApiOperationObject = { + operationId: "regenerateWebhookSecret", + "x-speakeasy-name-override": "regenerateSecret", + "x-speakeasy-max-method-params": 1, + summary: "Regenerate a webhook secret.", + description: + "Generate a new signing secret for a webhook. The old secret stops being used immediately.", + parameters: [webhookIdPathSchema], + responses: { + "200": { + description: "The webhook with its new secret", + content: { + "application/json": { + schema: successSchema(WebhookSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Webhooks"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const webhook = await agentset.webhooks.regenerateSecret("wh_xxx"); +console.log(webhook.secret); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/webhooks/update-webhook.ts b/apps/web/src/openapi/v1/webhooks/update-webhook.ts new file mode 100644 index 00000000..71c579c7 --- /dev/null +++ b/apps/web/src/openapi/v1/webhooks/update-webhook.ts @@ -0,0 +1,45 @@ +import type { ZodOpenApiOperationObject } from "zod-openapi"; +import { openApiErrorResponses, successSchema } from "@/openapi/responses"; +import { updateWebhookSchema, WebhookSchema } from "@/schemas/api/webhook"; + +import { makeCodeSamples, ts } from "../code-samples"; +import { webhookIdPathSchema } from "./utils"; + +export const updateWebhook: ZodOpenApiOperationObject = { + operationId: "updateWebhook", + "x-speakeasy-name-override": "update", + "x-speakeasy-max-method-params": 2, + summary: "Update a webhook.", + description: + "Update a webhook for the authenticated organization. Field updates require the Pro plan or above; enabling/disabling a webhook does not.", + parameters: [webhookIdPathSchema], + requestBody: { + required: true, + content: { + "application/json": { schema: updateWebhookSchema }, + }, + }, + responses: { + "200": { + description: "The updated webhook", + content: { + "application/json": { + schema: successSchema(WebhookSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Webhooks"], + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const updatedWebhook = await agentset.webhooks.update("wh_xxx", { + name: "Updated Webhook", + // enabled: false, +}); +console.log(updatedWebhook); +`, + { isNs: false }, + ), +}; diff --git a/apps/web/src/openapi/v1/webhooks/utils.ts b/apps/web/src/openapi/v1/webhooks/utils.ts new file mode 100644 index 00000000..167cdf1c --- /dev/null +++ b/apps/web/src/openapi/v1/webhooks/utils.ts @@ -0,0 +1,11 @@ +import { z } from "zod/v4"; + +export const webhookIdPathSchema = z.string().meta({ + examples: ["wh_123"], + description: "The id of the webhook (prefixed with wh_)", + param: { + in: "path", + name: "webhookId", + id: "WebhookIdRef", + }, +}); diff --git a/apps/web/src/schemas/api/api-key.ts b/apps/web/src/schemas/api/api-key.ts new file mode 100644 index 00000000..7204c551 --- /dev/null +++ b/apps/web/src/schemas/api/api-key.ts @@ -0,0 +1,33 @@ +import { z } from "zod/v4"; + +export const ApiKeySchema = z + .object({ + id: z.string().describe("The unique ID of the API key."), + label: z.string().describe("The label of the API key."), + scope: z.string().describe("The scope of the API key."), + organizationId: z + .string() + .describe("The ID of the organization that owns the API key."), + createdAt: z.date().describe("The date and time the API key was created."), + updatedAt: z + .date() + .describe("The date and time the API key was last updated."), + }) + .meta({ + id: "api-key", + title: "API Key", + }); + +export const CreatedApiKeySchema = ApiKeySchema.extend({ + key: z + .string() + .describe("The full API key. It is only returned once, at creation time."), +}).meta({ + id: "created-api-key", + title: "Created API Key", +}); + +export const createApiKeyBodySchema = z.object({ + label: z.string().min(1).describe("The label of the API key."), + scope: z.enum(["all"]).default("all").describe("The scope of the API key."), +}); diff --git a/apps/web/src/schemas/api/chat.ts b/apps/web/src/schemas/api/chat.ts new file mode 100644 index 00000000..66945276 --- /dev/null +++ b/apps/web/src/schemas/api/chat.ts @@ -0,0 +1,95 @@ +import { DEFAULT_SYSTEM_PROMPT } from "@/lib/prompts"; +import { z } from "zod/v4"; + +import { llmSchemaWithDefault } from "@agentset/validation"; + +import { NodeSchema } from "./node"; +import { baseQueryVectorStoreSchema } from "./query"; + +export const chatOptionsSchema = baseQueryVectorStoreSchema + .omit({ query: true, mode: true, keywordFilter: true }) + .extend({ + systemPrompt: z + .string() + .optional() + .default(DEFAULT_SYSTEM_PROMPT.compile()) + .describe( + "The system prompt to use for the chat. Defaults to the default system prompt.", + ), + temperature: z + .number() + .optional() + .describe("The temperature to use when generating the answer."), + mode: z + .enum(["normal", "agentic", "deepResearch"]) + .optional() + .default("normal") + .describe( + "The mode to use for the chat. `normal` runs a single retrieval, `agentic` lets the model generate and evaluate search queries, and `deepResearch` runs an iterative research pipeline. Defaults to `normal`.", + ), + llmModel: llmSchemaWithDefault.describe( + "The LLM to use when generating the answer.", + ), + }); + +export const chatMessageSchema = z + .object({ + role: z + .enum(["user", "assistant"]) + .describe("The role of the message author."), + content: z.string().min(1).describe("The text content of the message."), + }) + .meta({ + id: "chat-message", + title: "Chat Message", + }); + +export const chatSchema = chatOptionsSchema + .extend({ + messages: z + .array(chatMessageSchema) + .min(1) + .describe( + "The conversation so far. The last message must be from the user, it will be used as the query.", + ), + stream: z + .boolean() + .optional() + .default(false) + .describe( + "Whether to stream the response as an AI SDK UI message stream (server-sent events). Defaults to `false`.", + ), + }) + .check((ctx) => { + if (ctx.value.rerankLimit && ctx.value.rerankLimit > ctx.value.topK) { + ctx.issues.push({ + path: ["rerankLimit"], + code: "too_big", + message: "rerankLimit cannot be larger than topK", + inclusive: true, + type: "number", + maximum: ctx.value.topK, + input: ctx.value.rerankLimit, + origin: "number", + }); + } + }); + +export const chatResponseSchema = z + .object({ + message: z.object({ + role: z.literal("assistant").describe("The role of the message author."), + content: z + .string() + .describe("The text content of the generated message."), + }), + sources: NodeSchema.array() + .optional() + .describe( + "The chunks retrieved from the namespace to generate the answer. Not returned in `deepResearch` mode.", + ), + }) + .meta({ + id: "chat-response", + title: "Chat Response", + }); diff --git a/apps/web/src/schemas/api/hosting.ts b/apps/web/src/schemas/api/hosting.ts index 8e6836dd..71739415 100644 --- a/apps/web/src/schemas/api/hosting.ts +++ b/apps/web/src/schemas/api/hosting.ts @@ -8,6 +8,21 @@ import { rerankerSchema, } from "@agentset/validation"; +export const DomainSchema = z + .object({ + slug: z + .string() + .describe("The custom domain attached to the hosted interface."), + verified: z + .boolean() + .default(false) + .describe("Whether the domain ownership has been verified."), + }) + .meta({ + id: "domain", + title: "Domain", + }); + export const HostingSchema = z .object({ namespaceId: z @@ -23,6 +38,9 @@ export const HostingSchema = z .nullable() .default(null) .describe("The unique slug for accessing the hosted interface."), + domain: DomainSchema.nullable() + .default(null) + .describe("The custom domain attached to the hosted interface."), logo: z .string() .nullable() @@ -122,6 +140,82 @@ export const HostingSchema = z title: "Hosting", }); +export const domainVerificationStatusSchema = z.enum([ + "Valid Configuration", + "Invalid Configuration", + "Conflicting DNS Records", + "Pending Verification", + "Domain Not Found", + "Unknown Error", +]); + +export type DomainVerificationStatusProps = z.infer< + typeof domainVerificationStatusSchema +>; + +export const DomainStatusSchema = z + .object({ + domain: z.string().describe("The custom domain."), + status: domainVerificationStatusSchema.describe( + "The current verification status of the domain.", + ), + verified: z + .boolean() + .describe("Whether the domain is verified and correctly configured."), + apexName: z + .string() + .nullable() + .default(null) + .describe( + "The apex domain (e.g. example.com for docs.example.com). Null when the domain is not found on the project.", + ), + verification: z + .array( + z.object({ + type: z.string().describe("The type of the DNS record (e.g. TXT)."), + domain: z.string().describe("The domain to set the record on."), + value: z.string().describe("The value of the DNS record."), + reason: z.string().describe("The reason the record is required."), + }), + ) + .default([]) + .describe( + "DNS records required to verify domain ownership (present when the status is 'Pending Verification').", + ), + conflicts: z + .array( + z.object({ + type: z.string().describe("The type of the conflicting DNS record."), + name: z.string().describe("The name of the conflicting DNS record."), + value: z + .string() + .describe("The value of the conflicting DNS record."), + reason: z.string().describe("The reason the record conflicts."), + }), + ) + .default([]) + .describe( + "Conflicting DNS records that need to be removed (present when the status is 'Conflicting DNS Records').", + ), + misconfigured: z + .boolean() + .nullable() + .default(null) + .describe( + "Whether the domain's DNS records are misconfigured. Null when the configuration couldn't be determined.", + ), + }) + .meta({ + id: "domain-status", + title: "Domain Status", + }); + +export const addDomainSchema = z.object({ + domain: z + .string() + .describe("The custom domain to attach (e.g. docs.example.com)."), +}); + export const updateHostingSchema = z.object({ title: z.string().min(1).optional(), slug: slugSchema.optional(), diff --git a/apps/web/src/schemas/api/organization.ts b/apps/web/src/schemas/api/organization.ts new file mode 100644 index 00000000..5adafe47 --- /dev/null +++ b/apps/web/src/schemas/api/organization.ts @@ -0,0 +1,98 @@ +import { z } from "zod/v4"; + +// mirrors the slug validation in the create/update organization UI forms +export const organizationSlugSchema = z + .string() + .min(1) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Invalid slug format"); + +export const OrganizationSchema = z + .object({ + id: z.string().describe("The unique ID of the organization."), + name: z.string().describe("The name of the organization."), + slug: z.string().describe("The slug of the organization."), + plan: z.string().describe("The plan of the organization."), + createdAt: z + .date() + .describe("The date and time the organization was created."), + usage: z + .object({ + searchUsage: z + .number() + .describe("The number of searches used in the current cycle."), + searchLimit: z + .number() + .describe("The maximum number of searches allowed per cycle."), + totalPages: z + .number() + .describe("The total number of pages ingested by the organization."), + pagesLimit: z + .number() + .describe("The maximum number of pages allowed on the current plan."), + apiRatelimit: z + .number() + .describe("The API rate limit of the organization."), + }) + .describe("The usage and limits of the organization."), + }) + .meta({ + id: "organization", + title: "Organization", + }); + +export const updateOrganizationSchema = z + .object({ + name: z.string().min(1).optional(), + slug: organizationSlugSchema.optional(), + }) + .refine((data) => data.name !== undefined || data.slug !== undefined, { + message: "At least one field must be provided", + }); + +export const OrganizationMemberSchema = z + .object({ + id: z.string().describe("The unique ID of the member."), + role: z.string().describe("The role of the member."), + user: z.object({ + name: z.string().describe("The name of the member."), + email: z.string().describe("The email of the member."), + }), + createdAt: z + .date() + .describe("The date and time the member joined the organization."), + }) + .meta({ + id: "organization-member", + title: "Organization Member", + }); + +export const OrganizationInvitationSchema = z + .object({ + id: z.string().describe("The unique ID of the invitation."), + email: z.string().describe("The email the invitation was sent to."), + role: z + .string() + .nullable() + .default(null) + .describe("The role the invitee will have once they accept."), + status: z.string().describe("The status of the invitation."), + expiresAt: z.date().describe("The date and time the invitation expires."), + }) + .meta({ + id: "organization-invitation", + title: "Organization Invitation", + }); + +export const OrganizationMembersSchema = z + .object({ + members: z + .array(OrganizationMemberSchema) + .describe("The members of the organization."), + invitations: z + .array(OrganizationInvitationSchema) + .describe("The pending invitations to the organization."), + }) + .meta({ + id: "organization-members", + title: "Organization Members", + }); diff --git a/apps/web/src/schemas/api/webhook.ts b/apps/web/src/schemas/api/webhook.ts new file mode 100644 index 00000000..d14e7065 --- /dev/null +++ b/apps/web/src/schemas/api/webhook.ts @@ -0,0 +1,98 @@ +import { z } from "zod/v4"; + +import { WEBHOOK_TRIGGERS } from "@agentset/webhooks"; + +export const WebhookSchema = z + .object({ + id: z + .string() + .describe("The unique ID of the webhook (prefixed with wh_)."), + name: z.string().describe("The name of the webhook."), + url: z.string().describe("The URL webhook events are sent to."), + secret: z + .string() + .describe( + "The secret used to sign webhook payloads (prefixed with whsec_).", + ), + triggers: z + .array(z.enum(WEBHOOK_TRIGGERS)) + .describe("The events that trigger the webhook."), + disabledAt: z + .date() + .nullable() + .describe( + "The date and time the webhook was disabled. Null when the webhook is enabled.", + ), + namespaceIds: z + .array(z.string()) + .describe( + "The IDs of the namespaces the webhook is scoped to. An empty array means the webhook applies to all namespaces in the organization.", + ), + }) + .meta({ + id: "webhook", + title: "Webhook", + }); + +export const WebhookSummarySchema = WebhookSchema.omit({ + secret: true, +}).meta({ + id: "webhook-summary", + title: "Webhook Summary", +}); + +export const WebhookDetailsSchema = WebhookSchema.extend({ + consecutiveFailures: z + .number() + .describe("The number of consecutive failed deliveries."), + lastFailedAt: z + .date() + .nullable() + .describe("The date and time of the last failed delivery."), + createdAt: z.date().describe("The date and time the webhook was created."), +}).meta({ + id: "webhook-details", + title: "Webhook Details", +}); + +export const createWebhookSchema = z.object({ + name: z.string().min(1).max(40).describe("The name of the webhook."), + url: z.url().describe("The URL webhook events are sent to."), + secret: z + .string() + .optional() + .describe( + "A custom secret used to sign webhook payloads. Generated automatically when omitted.", + ), + triggers: z + .array(z.enum(WEBHOOK_TRIGGERS)) + .min(1) + .describe("The events that trigger the webhook."), + namespaceIds: z + .array(z.string()) + .optional() + .describe( + "The IDs of the namespaces to scope the webhook to (prefixed with ns_). When omitted or empty, the webhook applies to all namespaces in the organization.", + ), +}); + +export const updateWebhookSchema = createWebhookSchema + .omit({ secret: true }) + .partial() + .extend({ + enabled: z + .boolean() + .optional() + .describe( + "Enable or disable the webhook. Re-enabling a disabled webhook resets its failure count.", + ), + }) + .refine( + (body) => + Object.keys(body).some( + (key) => body[key as keyof typeof body] !== undefined, + ), + { + message: "At least one field must be provided.", + }, + ); diff --git a/apps/web/src/server/api/routers/api-keys.ts b/apps/web/src/server/api/routers/api-keys.ts index 1b04ee6d..888a5e09 100644 --- a/apps/web/src/server/api/routers/api-keys.ts +++ b/apps/web/src/server/api/routers/api-keys.ts @@ -1,9 +1,12 @@ -import { revalidateTag } from "next/cache"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createApiKey, createApiKeySchema } from "@/services/api-key/create"; +import { deleteApiKey } from "@/services/api-key/delete"; +import { listApiKeys } from "@/services/api-key/list"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { Prisma } from "@agentset/db"; + export const apiKeysRouter = createTRPCRouter({ getApiKeys: protectedProcedure .input( @@ -28,14 +31,7 @@ export const apiKeysRouter = createTRPCRouter({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - const apiKeys = await ctx.db.organizationApiKey.findMany({ - where: { - organizationId: input.orgId, - }, - omit: { - key: true, - }, - }); + const apiKeys = await listApiKeys({ organizationId: input.orgId }); return apiKeys; }), @@ -96,13 +92,19 @@ export const apiKeysRouter = createTRPCRouter({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - const apiKey = await ctx.db.organizationApiKey.delete({ - where: { - id: input.id, - }, - }); + try { + // scoped to the org so a member of one org can't delete another org's key + await deleteApiKey({ id: input.id, organizationId: input.orgId }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + throw new TRPCError({ code: "NOT_FOUND" }); + } - revalidateTag(`apiKey:${apiKey.key}`, "max"); + throw error; + } return { success: true }; }), diff --git a/apps/web/src/server/api/routers/domains.ts b/apps/web/src/server/api/routers/domains.ts index 8ea201ee..f35fbccb 100644 --- a/apps/web/src/server/api/routers/domains.ts +++ b/apps/web/src/server/api/routers/domains.ts @@ -1,27 +1,16 @@ import type { ProtectedProcedureContext } from "@/server/api/trpc"; -import { addDomainToVercel } from "@/lib/domains/add-domain"; -import { getConfigResponse } from "@/lib/domains/get-config-response"; -import { getDomainResponse } from "@/lib/domains/get-domain-response"; -import { removeDomainFromVercel } from "@/lib/domains/remove-domain"; -import { validateDomain } from "@/lib/domains/utils"; -import { verifyDomain } from "@/lib/domains/verify-domain"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { addDomain } from "@/services/domains/add"; +import { checkDomainStatus } from "@/services/domains/check-status"; +import { removeDomain } from "@/services/domains/remove"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; -import type { Domain } from "@agentset/db"; - const commonInput = z.object({ namespaceId: z.string(), }); -export type DomainVerificationStatusProps = - | "Valid Configuration" - | "Invalid Configuration" - | "Conflicting DNS Records" - | "Pending Verification" - | "Domain Not Found" - | "Unknown Error"; +export type { DomainVerificationStatusProps } from "@/schemas/api/hosting"; const getHosting = async ( ctx: ProtectedProcedureContext, @@ -41,198 +30,38 @@ const getHosting = async ( return hosting ?? null; }; +const getHostingOrThrow = async ( + ctx: ProtectedProcedureContext, + input: z.infer, +) => { + const hosting = await getHosting(ctx, input); + if (!hosting) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Hosting not found", + }); + } + + return hosting; +}; + export const domainsRouter = createTRPCRouter({ add: protectedProcedure .input(commonInput.extend({ domain: z.string() })) .mutation(async ({ ctx, input }) => { - const hosting = await getHosting(ctx, input); - if (!hosting) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Hosting not found", - }); - } - - // get domain from hosting - const domain = await ctx.db.domain.findUnique({ - where: { - hostingId: hosting.id, - }, - }); - - if (domain) { - throw new TRPCError({ - code: "CONFLICT", - message: "You already set a domain", - }); - } - - const validDomain = await validateDomain(input.domain); - - if (validDomain.error) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: validDomain.error, - }); - } - - const vercelResponse = await addDomainToVercel(input.domain); - if ( - vercelResponse.error && - vercelResponse.error.code !== "domain_already_in_use" // ignore this error - ) { - throw new TRPCError({ - code: "UNPROCESSABLE_CONTENT", - message: vercelResponse.error.message, - }); - } - - return ctx.db.domain.create({ - data: { - hostingId: hosting.id, - slug: input.domain, - }, - }); + const hosting = await getHostingOrThrow(ctx, input); + return addDomain({ hostingId: hosting.id, domain: input.domain }); }), checkStatus: protectedProcedure .input(commonInput) .query(async ({ ctx, input }) => { - const hosting = await getHosting(ctx, input); - if (!hosting) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Hosting not found", - }); - } - - const domain = await ctx.db.domain.findUnique({ - where: { - hostingId: hosting.id, - }, - }); - - if (!domain) { - throw new TRPCError({ code: "NOT_FOUND", message: "Domain not found" }); - } - - let status: DomainVerificationStatusProps = "Valid Configuration"; - - const [domainJson, configJson] = await Promise.all([ - getDomainResponse(domain.slug), - getConfigResponse(domain.slug), - ]); - - if (domainJson.error?.code === "not_found") { - // domain not found on Vercel project - status = "Domain Not Found"; - return { - status, - response: { configJson, domainJson }, - }; - } else if (domainJson.error) { - status = "Unknown Error"; - return { - status, - response: { configJson, domainJson }, - }; - } - - /** - * Domain has DNS conflicts - */ - if (configJson.conflicts && configJson.conflicts.length > 0) { - status = "Conflicting DNS Records"; - return { - status, - response: { configJson, domainJson }, - }; - } - - /** - * If domain is not verified, we try to verify now - */ - if (!domainJson.verified) { - status = "Pending Verification"; - const verificationJson = await verifyDomain(domain.slug); - if (verificationJson.verified) { - /** - * Domain was just verified - */ - status = "Valid Configuration"; - } - - return { - status, - response: { configJson, domainJson, verificationJson }, - }; - } - - let prismaResponse: Domain | null = null; - if (!configJson.misconfigured) { - prismaResponse = await ctx.db.domain.update({ - where: { - id: domain.id, - }, - data: { - verified: true, - lastChecked: new Date(), - }, - }); - } else { - status = "Invalid Configuration"; - prismaResponse = await ctx.db.domain.update({ - where: { - id: domain.id, - }, - data: { - verified: false, - lastChecked: new Date(), - }, - }); - } - - return { - status, - response: { configJson, domainJson, prismaResponse }, - }; + const hosting = await getHostingOrThrow(ctx, input); + return checkDomainStatus({ hostingId: hosting.id }); }), remove: protectedProcedure .input(commonInput) .mutation(async ({ ctx, input }) => { - const hosting = await getHosting(ctx, input); - if (!hosting) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Hosting not found", - }); - } - - const domain = await ctx.db.domain.findUnique({ - where: { - hostingId: hosting.id, - }, - }); - - if (!domain) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Domain not found", - }); - } - - const vercelResponse = await removeDomainFromVercel(domain.slug); - // ignore not_found error - if (vercelResponse.error && vercelResponse.error.code !== "not_found") { - throw new TRPCError({ - code: "UNPROCESSABLE_CONTENT", - message: vercelResponse.error.message, - }); - } - - return ctx.db.domain.delete({ - where: { - id: domain.id, - }, - }); + const hosting = await getHostingOrThrow(ctx, input); + return removeDomain({ hostingId: hosting.id }); }), }); diff --git a/apps/web/src/server/api/routers/ingest-jobs.ts b/apps/web/src/server/api/routers/ingest-jobs.ts index 60e79a2a..60f56050 100644 --- a/apps/web/src/server/api/routers/ingest-jobs.ts +++ b/apps/web/src/server/api/routers/ingest-jobs.ts @@ -1,4 +1,3 @@ -import { emitIngestJobWebhook } from "@/lib/webhook/emit"; import { createIngestJobSchema, getIngestionJobsSchema, @@ -6,15 +5,11 @@ import { import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createIngestJob } from "@/services/ingest-jobs/create"; import { deleteIngestJob } from "@/services/ingest-jobs/delete"; +import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; import { getPaginationArgs, paginateResults } from "@/services/pagination"; import { TRPCError } from "@trpc/server"; -import { waitUntil } from "@vercel/functions"; import { z } from "zod/v4"; -import { IngestJobStatus } from "@agentset/db"; -import { triggerReIngestJob } from "@agentset/jobs"; -import { isFreePlan } from "@agentset/stripe/plans"; - import { getNamespaceByUser } from "../auth"; export const ingestJobRouter = createTRPCRouter({ @@ -113,23 +108,10 @@ export const ingestJobRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND" }); } - // if it's not a pro plan, check if the user has exceeded the limit - // pro plan is unlimited with usage based billing - if ( - isFreePlan(organization.plan) && - organization.totalPages >= organization.pagesLimit - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You've reached the maximum number of pages.", - }); - } - return await createIngestJob({ data: input, - organizationId: namespace.organizationId, + organization, namespaceId: namespace.id, - plan: organization.plan, }); }), delete: protectedProcedure @@ -143,31 +125,11 @@ export const ingestJobRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND" }); } - const ingestJob = await ctx.db.ingestJob.findUnique({ - where: { - id: input.jobId, - namespaceId: namespace.id, - }, - select: { id: true, status: true }, - }); - - if (!ingestJob) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - if ( - ingestJob.status === IngestJobStatus.QUEUED_FOR_DELETE || - ingestJob.status === IngestJobStatus.DELETING - ) { - throw new TRPCError({ code: "BAD_REQUEST" }); - } - - const updatedIngestJob = await deleteIngestJob({ - jobId: ingestJob.id, + return await deleteIngestJob({ + jobId: input.jobId, + namespaceId: namespace.id, organizationId: namespace.organizationId, }); - - return updatedIngestJob; }), reIngest: protectedProcedure .input(z.object({ jobId: z.string(), namespaceId: z.string() })) @@ -180,70 +142,11 @@ export const ingestJobRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND" }); } - const [ingestJob, organization] = await Promise.all([ - ctx.db.ingestJob.findUnique({ - where: { - id: input.jobId, - namespaceId: namespace.id, - }, - select: { id: true, status: true }, - }), - ctx.db.organization.findUnique({ - where: { id: namespace.organizationId }, - select: { plan: true }, - }), - ]); - - if (!ingestJob || !organization) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - if ( - ingestJob.status === IngestJobStatus.PRE_PROCESSING || - ingestJob.status === IngestJobStatus.PROCESSING - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Job is already being processed", - }); - } - - const handle = await triggerReIngestJob( - { - jobId: ingestJob.id, - }, - organization.plan, - ); - - const updatedJob = await ctx.db.ingestJob.update({ - where: { id: ingestJob.id }, - data: { - status: IngestJobStatus.QUEUED_FOR_RESYNC, - queuedAt: new Date(), - workflowRunsIds: { push: handle.id }, - }, - select: { - id: true, - name: true, - namespaceId: true, - status: true, - error: true, - createdAt: true, - updatedAt: true, - }, + // the service fetches the org plan in parallel with the job lookup + return await reIngestJob({ + jobId: input.jobId, + namespaceId: namespace.id, + organizationId: namespace.organizationId, }); - - // Emit ingest_job.queued_for_resync webhook - waitUntil( - emitIngestJobWebhook({ - trigger: "ingest_job.queued_for_resync", - ingestJob: { - ...updatedJob, - organizationId: namespace.organizationId, - }, - }), - ); - - return ingestJob; }), }); diff --git a/apps/web/src/server/api/routers/namespaces.ts b/apps/web/src/server/api/routers/namespaces.ts index feebd3d1..4d141958 100644 --- a/apps/web/src/server/api/routers/namespaces.ts +++ b/apps/web/src/server/api/routers/namespaces.ts @@ -1,11 +1,8 @@ import type { ProtectedProcedureContext } from "@/server/api/trpc"; import { createNamespaceSchema } from "@/schemas/api/namespace"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { createNamespace } from "@/services/namespaces/create"; import { deleteNamespace } from "@/services/namespaces/delete"; -import { - validateEmbeddingModel, - validateVectorStoreConfig, -} from "@/services/namespaces/validate"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; @@ -150,47 +147,13 @@ export const namespaceRouter = createTRPCRouter({ }).shape, ), ) - .mutation(async ({ ctx, input }) => { - await validateIsMember(ctx, input.orgId, ["admin", "owner"]); - - const { success: isValidEmbedding, error: embeddingError } = - await validateEmbeddingModel(input.embeddingConfig); - if (!isValidEmbedding) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: embeddingError, - }); - } - - const { success: isValidVectorStore, error: vectorStoreError } = - await validateVectorStoreConfig( - input.vectorStoreConfig, - input.embeddingConfig, - ); - if (!isValidVectorStore) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: vectorStoreError, - }); - } - - const [namespace] = await ctx.db.$transaction([ - ctx.db.namespace.create({ - data: { - name: input.name, - slug: input.slug, - organizationId: input.orgId, - embeddingConfig: input.embeddingConfig, - vectorStoreConfig: input.vectorStoreConfig, - }, - }), - ctx.db.organization.update({ - where: { id: input.orgId }, - data: { totalNamespaces: { increment: 1 } }, - }), - ]); + .mutation(async ({ ctx, input: { orgId, ...data } }) => { + await validateIsMember(ctx, orgId, ["admin", "owner"]); - return namespace; + return await createNamespace({ + organizationId: orgId, + data, + }); }), createDemoNamespace: protectedProcedure .input( diff --git a/apps/web/src/server/api/routers/organizations.ts b/apps/web/src/server/api/routers/organizations.ts index daa96e34..932b49df 100644 --- a/apps/web/src/server/api/routers/organizations.ts +++ b/apps/web/src/server/api/routers/organizations.ts @@ -1,4 +1,5 @@ import { deleteOrganization } from "@/services/organizations/delete"; +import { getOrganizationMembers } from "@/services/organizations/members"; import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; @@ -86,36 +87,9 @@ export const organizationsRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { - const members = await ctx.db.organization.findUnique({ - where: { - id: input.organizationId, - members: { - some: { - userId: ctx.session.user.id, - }, - }, - }, - select: { - members: { - include: { - user: { - select: { - name: true, - email: true, - image: true, - }, - }, - }, - orderBy: { - createdAt: "asc", - }, - }, - invitations: { - where: { - status: "pending", - }, - }, - }, + const members = await getOrganizationMembers({ + organizationId: input.organizationId, + userId: ctx.session.user.id, }); return members; diff --git a/apps/web/src/server/api/routers/webhooks.ts b/apps/web/src/server/api/routers/webhooks.ts index a20ce9c7..35103a2e 100644 --- a/apps/web/src/server/api/routers/webhooks.ts +++ b/apps/web/src/server/api/routers/webhooks.ts @@ -1,18 +1,18 @@ -import { createWebhook } from "@/lib/webhook/create-webhook"; -import { getWebhooks } from "@/lib/webhook/get-webhooks"; import { samplePayload } from "@/lib/webhook/sample-events/payload"; -import { createWebhookSecret } from "@/lib/webhook/secret"; -import { transformWebhook } from "@/lib/webhook/transform"; -import { toggleWebhooksForOrganization } from "@/lib/webhook/update-webhook"; -import { validateWebhook } from "@/lib/webhook/validate-webhook"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { createWebhook } from "@/services/webhooks/create"; +import { deleteWebhook } from "@/services/webhooks/delete"; +import { getWebhook } from "@/services/webhooks/get"; +import { listWebhooks } from "@/services/webhooks/list"; +import { requireWebhooksPlan } from "@/services/webhooks/plan"; +import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; +import { toggleWebhook } from "@/services/webhooks/toggle"; +import { updateWebhook } from "@/services/webhooks/update"; import { TRPCError } from "@trpc/server"; import { nanoid } from "nanoid"; import { z } from "zod/v4"; -import type { WebhookTrigger } from "@agentset/webhooks"; import { triggerSendWebhook } from "@agentset/jobs"; -import { isFreePlan } from "@agentset/stripe/plans"; import { getWebhookEvents } from "@agentset/tinybird"; import { createWebhookSchema, @@ -21,18 +21,6 @@ import { WEBHOOK_TRIGGERS, webhookPayloadSchema, } from "@agentset/webhooks"; -import { webhookCache } from "@agentset/webhooks/server"; - -// Helper to check if organization has webhooks access (pro plan required) -const requireProPlan = (plan: string) => { - if (isFreePlan(plan)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Webhooks are only available on the Pro plan and above. Please upgrade to use this feature.", - }); - } -}; export const webhooksRouter = createTRPCRouter({ // List all webhooks for an organization @@ -51,11 +39,7 @@ export const webhooksRouter = createTRPCRouter({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - const webhooks = await getWebhooks({ - organizationId: input.organizationId, - }); - - return webhooks.map(transformWebhook); + return listWebhooks({ organizationId: input.organizationId }); }), // Get a single webhook by ID @@ -73,39 +57,16 @@ export const webhooksRouter = createTRPCRouter({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - const webhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - select: { - id: true, - name: true, - url: true, - secret: true, - triggers: true, - disabledAt: true, - consecutiveFailures: true, - lastFailedAt: true, - createdAt: true, - namespaces: { - select: { - namespaceId: true, - }, - }, - }, + const webhook = await getWebhook({ + organizationId: input.organizationId, + webhookId: input.webhookId, }); if (!webhook) { throw new TRPCError({ code: "NOT_FOUND" }); } - return { - ...transformWebhook(webhook), - consecutiveFailures: webhook.consecutiveFailures, - lastFailedAt: webhook.lastFailedAt, - createdAt: webhook.createdAt, - }; + return webhook; }), // Get webhook events from Tinybird @@ -161,14 +122,9 @@ export const webhooksRouter = createTRPCRouter({ } // Check pro plan requirement - requireProPlan(member.organization.plan); + requireWebhooksPlan(member.organization.plan); - await validateWebhook({ - input, - organizationId: input.organizationId, - }); - - const webhook = await createWebhook({ + return createWebhook({ name: input.name, url: input.url, secret: input.secret, @@ -176,8 +132,6 @@ export const webhooksRouter = createTRPCRouter({ namespaceIds: input.namespaceIds, organizationId: input.organizationId, }); - - return transformWebhook(webhook); }), // Update a webhook @@ -205,66 +159,23 @@ export const webhooksRouter = createTRPCRouter({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - requireProPlan(member.organization.plan); + requireWebhooksPlan(member.organization.plan); - const existingWebhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, + const webhook = await updateWebhook({ + organizationId: input.organizationId, + webhookId: input.webhookId, + name: input.name, + url: input.url, + secret: input.secret, + triggers: input.triggers, + namespaceIds: input.namespaceIds, }); - if (!existingWebhook) { + if (!webhook) { throw new TRPCError({ code: "NOT_FOUND" }); } - await validateWebhook({ - input, - organizationId: input.organizationId, - webhook: existingWebhook, - }); - - const { name, url, triggers, namespaceIds } = input; - - const webhook = await ctx.db.webhook.update({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - data: { - ...(name && { name }), - ...(url && { url }), - ...(triggers && { triggers }), - ...(namespaceIds !== undefined && { - namespaces: { - deleteMany: {}, - ...(namespaceIds.length > 0 && { - create: namespaceIds.map((namespaceId) => ({ - namespaceId, - })), - }), - }, - }), - }, - select: { - id: true, - name: true, - url: true, - secret: true, - triggers: true, - disabledAt: true, - namespaces: { - select: { - namespaceId: true, - }, - }, - }, - }); - - // Invalidate webhook cache - await webhookCache.invalidateOrg(input.organizationId); - - return transformWebhook(webhook); + return webhook; }), // Delete a webhook @@ -282,25 +193,15 @@ export const webhooksRouter = createTRPCRouter({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - const webhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, + const webhook = await deleteWebhook({ + organizationId: input.organizationId, + webhookId: input.webhookId, }); if (!webhook) { throw new TRPCError({ code: "NOT_FOUND" }); } - await ctx.db.webhook.delete({ - where: { id: input.webhookId }, - }); - - await toggleWebhooksForOrganization({ - organizationId: input.organizationId, - }); - return { success: true }; }), @@ -319,36 +220,15 @@ export const webhooksRouter = createTRPCRouter({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - const webhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - select: { disabledAt: true }, + const updatedWebhook = await toggleWebhook({ + organizationId: input.organizationId, + webhookId: input.webhookId, }); - if (!webhook) { + if (!updatedWebhook) { throw new TRPCError({ code: "NOT_FOUND" }); } - const disabledAt = webhook.disabledAt ? null : new Date(); - - const updatedWebhook = await ctx.db.webhook.update({ - where: { id: input.webhookId }, - data: { - disabledAt, - // Reset failure count when re-enabling - ...(webhook.disabledAt && { - consecutiveFailures: 0, - lastFailedAt: null, - }), - }, - }); - - await toggleWebhooksForOrganization({ - organizationId: input.organizationId, - }); - return { disabledAt: updatedWebhook.disabledAt }; }), @@ -393,7 +273,7 @@ export const webhooksRouter = createTRPCRouter({ id: `${WEBHOOK_EVENT_ID_PREFIX}${nanoid(25)}`, event: input.trigger, createdAt: new Date().toISOString(), - data: samplePayload[input.trigger as WebhookTrigger], + data: samplePayload[input.trigger], }); await triggerSendWebhook({ @@ -423,28 +303,16 @@ export const webhooksRouter = createTRPCRouter({ throw new TRPCError({ code: "UNAUTHORIZED" }); } - const webhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, + const updatedWebhook = await regenerateWebhookSecret({ + organizationId: input.organizationId, + webhookId: input.webhookId, }); - if (!webhook) { + if (!updatedWebhook) { throw new TRPCError({ code: "NOT_FOUND" }); } - const newSecret = createWebhookSecret(); - - await ctx.db.webhook.update({ - where: { id: input.webhookId }, - data: { secret: newSecret }, - }); - - // Invalidate webhook cache - await webhookCache.invalidateOrg(input.organizationId); - - return { secret: newSecret }; + return { secret: updatedWebhook.secret }; }), // Get namespaces for webhook namespace selector diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts index dee58634..83d8e6b4 100644 --- a/apps/web/src/server/api/trpc.ts +++ b/apps/web/src/server/api/trpc.ts @@ -161,18 +161,20 @@ export const publicProcedure = t.procedure.use(timingMiddleware); * * @see https://trpc.io/docs/procedures */ -export const protectedProcedure = t.procedure.use(({ ctx, next }) => { - if (!ctx.session) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } +export const protectedProcedure = t.procedure + .use(timingMiddleware) + .use(({ ctx, next }) => { + if (!ctx.session) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } - return next({ - ctx: { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - session: ctx.session as NonNullable, - }, + return next({ + ctx: { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + session: ctx.session as NonNullable, + }, + }); }); -}); export type ProcedureContext = Awaited>; diff --git a/apps/web/src/services/api-key/delete.ts b/apps/web/src/services/api-key/delete.ts new file mode 100644 index 00000000..2bdb02a0 --- /dev/null +++ b/apps/web/src/services/api-key/delete.ts @@ -0,0 +1,23 @@ +import { revalidateTag } from "next/cache"; + +import { db } from "@agentset/db/client"; + +export const deleteApiKey = async ({ + id, + organizationId, +}: { + id: string; + organizationId: string; +}) => { + // scoping the delete to the organization prevents deleting another org's key + const apiKey = await db.organizationApiKey.delete({ + where: { + id, + organizationId, + }, + }); + + revalidateTag(`apiKey:${apiKey.key}`, "max"); + + return apiKey; +}; diff --git a/apps/web/src/services/api-key/list.ts b/apps/web/src/services/api-key/list.ts new file mode 100644 index 00000000..8c0d4b95 --- /dev/null +++ b/apps/web/src/services/api-key/list.ts @@ -0,0 +1,16 @@ +import { db } from "@agentset/db/client"; + +export const listApiKeys = async ({ + organizationId, +}: { + organizationId: string; +}) => { + return await db.organizationApiKey.findMany({ + where: { + organizationId, + }, + omit: { + key: true, + }, + }); +}; diff --git a/apps/web/src/services/chat.ts b/apps/web/src/services/chat.ts new file mode 100644 index 00000000..c030b29f --- /dev/null +++ b/apps/web/src/services/chat.ts @@ -0,0 +1,16 @@ +import type { chatMessageSchema } from "@/schemas/api/chat"; +import type { ModelMessage } from "ai"; +import type { z } from "zod/v4"; + +/** + * Maps public-API chat messages to AI SDK `ModelMessage`s. Shared by the + * REST chat route and the MCP chat tool. + */ +export const toModelMessages = ( + messages: z.infer[], +): ModelMessage[] => + messages.map((message) => + message.role === "user" + ? { role: "user", content: message.content } + : { role: "assistant", content: message.content }, + ); diff --git a/apps/web/src/services/documents/delete.ts b/apps/web/src/services/documents/delete.ts index 5a151e1f..67876b89 100644 --- a/apps/web/src/services/documents/delete.ts +++ b/apps/web/src/services/documents/delete.ts @@ -1,3 +1,4 @@ +import { AgentsetApiError } from "@/lib/api/errors"; import { emitDocumentWebhook } from "@/lib/webhook/emit"; import { waitUntil } from "@vercel/functions"; @@ -5,6 +6,8 @@ import { DocumentStatus } from "@agentset/db"; import { db } from "@agentset/db/client"; import { triggerDeleteDocument } from "@agentset/jobs"; +import { getDocumentOrThrow } from "./get"; + export const deleteDocument = async ({ documentId, organizationId, @@ -40,3 +43,38 @@ export const deleteDocument = async ({ return updatedDoc; }; + +/** + * Looks up a document (scoped to the namespace and, when provided, the + * tenant), validates it isn't already being deleted, then queues it for + * deletion via {@link deleteDocument}. + */ +export const queueDocumentDeletion = async ({ + namespaceId, + organizationId, + documentId, + tenantId, +}: { + namespaceId: string; + organizationId: string; + documentId: string; + tenantId?: string; +}) => { + const document = await getDocumentOrThrow({ + namespaceId, + documentId, + tenantId, + }); + + if ( + document.status === DocumentStatus.QUEUED_FOR_DELETE || + document.status === DocumentStatus.DELETING + ) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Document is already being deleted", + }); + } + + return deleteDocument({ documentId: document.id, organizationId }); +}; diff --git a/apps/web/src/services/documents/download.ts b/apps/web/src/services/documents/download.ts new file mode 100644 index 00000000..2c80d9c6 --- /dev/null +++ b/apps/web/src/services/documents/download.ts @@ -0,0 +1,68 @@ +import { AgentsetApiError } from "@/lib/api/errors"; + +import { DocumentStatus } from "@agentset/db"; +import { presignChunksDownloadUrl, presignGetUrl } from "@agentset/storage"; + +import { getDocumentOrThrow } from "./get"; + +export const getDocumentChunksDownloadUrl = async ({ + namespaceId, + documentId, + tenantId, +}: { + namespaceId: string; + documentId: string; + tenantId?: string; +}) => { + const document = await getDocumentOrThrow({ + namespaceId, + documentId, + tenantId, + }); + + if (document.status !== DocumentStatus.COMPLETED) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Chunks are only available for completed documents", + }); + } + + const url = await presignChunksDownloadUrl(namespaceId, document.id); + if (!url) { + throw new AgentsetApiError({ + code: "not_found", + message: "Chunks file not found", + }); + } + + return { url }; +}; + +export const getDocumentFileDownloadUrl = async ({ + namespaceId, + documentId, + tenantId, +}: { + namespaceId: string; + documentId: string; + tenantId?: string; +}) => { + const document = await getDocumentOrThrow({ + namespaceId, + documentId, + tenantId, + }); + + if (document.source.type !== "MANAGED_FILE") { + throw new AgentsetApiError({ + code: "bad_request", + message: "File download is only available for managed files", + }); + } + + const { url } = await presignGetUrl(document.source.key, { + fileName: document.name ?? undefined, + }); + + return { url }; +}; diff --git a/apps/web/src/services/documents/get.ts b/apps/web/src/services/documents/get.ts new file mode 100644 index 00000000..98021376 --- /dev/null +++ b/apps/web/src/services/documents/get.ts @@ -0,0 +1,47 @@ +import { AgentsetApiError } from "@/lib/api/errors"; + +import { db } from "@agentset/db/client"; +import { normalizeId } from "@agentset/utils"; + +/** + * Finds a document by ID scoped to a namespace, and to a tenant when one is + * provided (e.g. via the `x-tenant-id` header). A document belonging to a + * different tenant is treated as not found. + * + * Throws the same errors as the public API: `bad_request` for an invalid ID + * and `not_found` when the document doesn't exist. + */ +export const getDocumentOrThrow = async ({ + namespaceId, + documentId: rawDocumentId, + tenantId, +}: { + namespaceId: string; + documentId: string; + tenantId?: string; +}) => { + const documentId = normalizeId(rawDocumentId, "doc_"); + if (!documentId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid document ID", + }); + } + + const document = await db.document.findUnique({ + where: { + id: documentId, + namespaceId, + tenantId, + }, + }); + + if (!document) { + throw new AgentsetApiError({ + code: "not_found", + message: "Document not found", + }); + } + + return document; +}; diff --git a/apps/web/src/services/domains/add.ts b/apps/web/src/services/domains/add.ts new file mode 100644 index 00000000..5110d8d2 --- /dev/null +++ b/apps/web/src/services/domains/add.ts @@ -0,0 +1,58 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { addDomainToVercel } from "@/lib/domains/add-domain"; +import { validateDomain } from "@/lib/domains/utils"; + +import { db } from "@agentset/db/client"; + +import { assertVercelConfigured } from "./utils"; + +export const addDomain = async ({ + hostingId, + domain, +}: { + hostingId: string; + domain: string; +}) => { + // get domain from hosting + const existingDomain = await db.domain.findUnique({ + where: { + hostingId, + }, + }); + + if (existingDomain) { + throw new AgentsetApiError({ + code: "conflict", + message: "You already set a domain", + }); + } + + const validDomain = await validateDomain(domain); + + if (validDomain.error) { + throw new AgentsetApiError({ + code: "bad_request", + message: validDomain.error, + }); + } + + assertVercelConfigured(); + + const vercelResponse = await addDomainToVercel(domain); + if ( + vercelResponse.error && + vercelResponse.error.code !== "domain_already_in_use" // ignore this error + ) { + throw new AgentsetApiError({ + code: "unprocessable_entity", + message: vercelResponse.error.message, + }); + } + + return db.domain.create({ + data: { + hostingId, + slug: domain, + }, + }); +}; diff --git a/apps/web/src/services/domains/check-status.ts b/apps/web/src/services/domains/check-status.ts new file mode 100644 index 00000000..b7e47b51 --- /dev/null +++ b/apps/web/src/services/domains/check-status.ts @@ -0,0 +1,112 @@ +import type { DomainVerificationStatusProps } from "@/schemas/api/hosting"; +import { AgentsetApiError } from "@/lib/api/errors"; +import { getConfigResponse } from "@/lib/domains/get-config-response"; +import { getDomainResponse } from "@/lib/domains/get-domain-response"; +import { verifyDomain } from "@/lib/domains/verify-domain"; + +import type { Domain } from "@agentset/db"; +import { db } from "@agentset/db/client"; + +import { assertVercelConfigured } from "./utils"; + +export const checkDomainStatus = async ({ + hostingId, +}: { + hostingId: string; +}) => { + const domain = await db.domain.findUnique({ + where: { + hostingId, + }, + }); + + if (!domain) { + throw new AgentsetApiError({ + code: "not_found", + message: "Domain not found", + }); + } + + assertVercelConfigured(); + + let status: DomainVerificationStatusProps = "Valid Configuration"; + + const [domainJson, configJson] = await Promise.all([ + getDomainResponse(domain.slug), + getConfigResponse(domain.slug), + ]); + + if (domainJson.error?.code === "not_found") { + // domain not found on Vercel project + status = "Domain Not Found"; + return { + status, + response: { configJson, domainJson }, + }; + } else if (domainJson.error) { + status = "Unknown Error"; + return { + status, + response: { configJson, domainJson }, + }; + } + + /** + * Domain has DNS conflicts + */ + if (configJson.conflicts && configJson.conflicts.length > 0) { + status = "Conflicting DNS Records"; + return { + status, + response: { configJson, domainJson }, + }; + } + + /** + * If domain is not verified, we try to verify now + */ + if (!domainJson.verified) { + status = "Pending Verification"; + const verificationJson = await verifyDomain(domain.slug); + if (verificationJson.verified) { + /** + * Domain was just verified + */ + status = "Valid Configuration"; + } + + return { + status, + response: { configJson, domainJson, verificationJson }, + }; + } + + let prismaResponse: Domain | null = null; + if (!configJson.misconfigured) { + prismaResponse = await db.domain.update({ + where: { + id: domain.id, + }, + data: { + verified: true, + lastChecked: new Date(), + }, + }); + } else { + status = "Invalid Configuration"; + prismaResponse = await db.domain.update({ + where: { + id: domain.id, + }, + data: { + verified: false, + lastChecked: new Date(), + }, + }); + } + + return { + status, + response: { configJson, domainJson, prismaResponse }, + }; +}; diff --git a/apps/web/src/services/domains/remove.ts b/apps/web/src/services/domains/remove.ts new file mode 100644 index 00000000..c78151b5 --- /dev/null +++ b/apps/web/src/services/domains/remove.ts @@ -0,0 +1,44 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { removeDomainFromVercel } from "@/lib/domains/remove-domain"; +import { getCache } from "@vercel/functions"; + +import { db } from "@agentset/db/client"; + +import { assertVercelConfigured } from "./utils"; + +export const removeDomain = async ({ hostingId }: { hostingId: string }) => { + const domain = await db.domain.findUnique({ + where: { + hostingId, + }, + }); + + if (!domain) { + throw new AgentsetApiError({ + code: "not_found", + message: "Domain not found", + }); + } + + assertVercelConfigured(); + + const vercelResponse = await removeDomainFromVercel(domain.slug); + // ignore not_found error + if (vercelResponse.error && vercelResponse.error.code !== "not_found") { + throw new AgentsetApiError({ + code: "unprocessable_entity", + message: vercelResponse.error.message, + }); + } + + const deletedDomain = await db.domain.delete({ + where: { + id: domain.id, + }, + }); + + // Expire the middleware hosting cache so the removed domain stops resolving + await getCache().expireTag(`hosting:${hostingId}`); + + return deletedDomain; +}; diff --git a/apps/web/src/services/domains/utils.ts b/apps/web/src/services/domains/utils.ts new file mode 100644 index 00000000..e4b934eb --- /dev/null +++ b/apps/web/src/services/domains/utils.ts @@ -0,0 +1,16 @@ +import { env } from "@/env"; +import { AgentsetApiError } from "@/lib/api/errors"; + +// self-hosted deployments may not have Vercel credentials configured +export const isVercelConfigured = () => + Boolean(env.VERCEL_PROJECT_ID && env.VERCEL_TEAM_ID && env.VERCEL_API_TOKEN); + +export const assertVercelConfigured = () => { + if (!isVercelConfigured()) { + throw new AgentsetApiError({ + code: "unprocessable_entity", + message: + "Custom domains require Vercel configuration. Set the VERCEL_PROJECT_ID, VERCEL_TEAM_ID, and VERCEL_API_TOKEN environment variables.", + }); + } +}; diff --git a/apps/web/src/services/hosting/delete.ts b/apps/web/src/services/hosting/delete.ts index a7b1c4cf..2c762065 100644 --- a/apps/web/src/services/hosting/delete.ts +++ b/apps/web/src/services/hosting/delete.ts @@ -1,5 +1,7 @@ import { env } from "@/env"; import { AgentsetApiError } from "@/lib/api/errors"; +import { removeDomain } from "@/services/domains/remove"; +import { isVercelConfigured } from "@/services/domains/utils"; import { getCache, waitUntil } from "@vercel/functions"; import { Prisma } from "@agentset/db"; @@ -11,18 +13,35 @@ export const deleteHosting = async ({ }: { namespaceId: string; }) => { - try { - const hosting = await db.hosting.delete({ - where: { namespaceId }, - }); + const hosting = await db.hosting.findFirst({ + where: { namespaceId }, + include: { domain: true }, + }); - // Expire cache - await getCache().expireTag(`hosting:${hosting.id}`); + if (!hosting) { + throw new AgentsetApiError({ + code: "not_found", + message: "Hosting is not enabled for this namespace", + }); + } - // Delete logo if it exists - if (hosting.logo) { - waitUntil(deleteAsset(hosting.logo.replace(`${env.ASSETS_S3_URL}/`, ""))); + // remove the custom domain from the Vercel project before the db row cascades. + // best-effort: a Vercel API failure must not block hosting deletion + if (hosting.domain && isVercelConfigured()) { + try { + await removeDomain({ hostingId: hosting.id }); + } catch (error) { + console.error( + `Failed to remove domain ${hosting.domain.slug} from Vercel while deleting hosting ${hosting.id}`, + error, + ); } + } + + try { + await db.hosting.delete({ + where: { id: hosting.id }, + }); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && @@ -35,4 +54,12 @@ export const deleteHosting = async ({ } throw error; } + + // Expire cache + await getCache().expireTag(`hosting:${hosting.id}`); + + // Delete logo if it exists + if (hosting.logo) { + waitUntil(deleteAsset(hosting.logo.replace(`${env.ASSETS_S3_URL}/`, ""))); + } }; diff --git a/apps/web/src/services/hosting/update.ts b/apps/web/src/services/hosting/update.ts index 67bad869..6eb8c40c 100644 --- a/apps/web/src/services/hosting/update.ts +++ b/apps/web/src/services/hosting/update.ts @@ -65,6 +65,9 @@ export const updateHosting = async ({ where: { id: hosting.id, }, + include: { + domain: true, + }, data: { title: input.title, ...(input.slug && { slug: input.slug }), diff --git a/apps/web/src/services/ingest-jobs/create.ts b/apps/web/src/services/ingest-jobs/create.ts index 8b1b20dc..6ac99b92 100644 --- a/apps/web/src/services/ingest-jobs/create.ts +++ b/apps/web/src/services/ingest-jobs/create.ts @@ -1,29 +1,49 @@ import type { createIngestJobSchema } from "@/schemas/api/ingest-job"; import type { z } from "zod/v4"; +import { AgentsetApiError, exceededLimitError } from "@/lib/api/errors"; import { emitIngestJobWebhook } from "@/lib/webhook/emit"; import { waitUntil } from "@vercel/functions"; +import type { IngestJob, Organization } from "@agentset/db"; import type { IngestJobBatchItem } from "@agentset/validation"; -import { IngestJobStatus } from "@agentset/db"; +import { IngestJobStatus, Prisma } from "@agentset/db"; import { db } from "@agentset/db/client"; import { triggerIngestionJob } from "@agentset/jobs"; import { checkFileExists } from "@agentset/storage"; +import { isFreePlan } from "@agentset/stripe/plans"; import { validateNamespaceFileKey } from "../uploads"; export const createIngestJob = async ({ - plan, - organizationId, + organization, namespaceId, tenantId, data, }: { - plan: string; - organizationId: string; + organization: Pick< + Organization, + "id" | "plan" | "totalPages" | "pagesLimit" + >; namespaceId: string; tenantId?: string; data: z.infer; }) => { + // if it's not a pro plan, check if the user has exceeded the limit + // pro plan is unlimited with usage based billing + if ( + isFreePlan(organization.plan) && + organization.totalPages >= organization.pagesLimit + ) { + throw new AgentsetApiError({ + code: "rate_limit_exceeded", + message: exceededLimitError({ + plan: organization.plan, + limit: organization.pagesLimit, + type: "pages", + }), + }); + } + let finalPayload: PrismaJson.IngestJobPayload | null = null; if (data.payload.type === "BATCH") { @@ -58,7 +78,10 @@ export const createIngestJob = async ({ const invalidKey = results.some((result) => !result); if (invalidKey) { - throw new Error("FILE_NOT_FOUND"); + throw new AgentsetApiError({ + code: "bad_request", + message: "File not found", + }); } } @@ -88,10 +111,13 @@ export const createIngestJob = async ({ fileUrl: data.payload.fileUrl, ...commonPayload, }; - } else if (data.payload.type === "MANAGED_FILE") { + } else { const exists = await checkFileExists(data.payload.key); if (!exists) { - throw new Error("FILE_NOT_FOUND"); + throw new AgentsetApiError({ + code: "bad_request", + message: "File not found", + }); } finalPayload = { @@ -102,11 +128,7 @@ export const createIngestJob = async ({ } } - if (!finalPayload) { - throw new Error("INVALID_PAYLOAD"); - } - - let config = structuredClone(data.config); + const config = structuredClone(data.config); if ( config && (finalPayload.type === "TEXT" || @@ -137,38 +159,53 @@ export const createIngestJob = async ({ if (config?.forceOcr !== undefined) delete config.forceOcr; if (config?.useLlm !== undefined) delete config.useLlm; - const [job] = await db.$transaction([ - db.ingestJob.create({ - data: { - namespace: { connect: { id: namespaceId } }, - tenantId, - status: IngestJobStatus.QUEUED, - name: data.name, - config: config, - externalId: data.externalId, - payload: finalPayload, - }, - }), - db.namespace.update({ - where: { id: namespaceId }, - data: { - totalIngestJobs: { increment: 1 }, - organization: { - update: { - totalIngestJobs: { increment: 1 }, + let job: IngestJob; + try { + [job] = await db.$transaction([ + db.ingestJob.create({ + data: { + namespace: { connect: { id: namespaceId } }, + tenantId, + status: IngestJobStatus.QUEUED, + name: data.name, + config: config, + externalId: data.externalId, + payload: finalPayload, + }, + }), + db.namespace.update({ + where: { id: namespaceId }, + data: { + totalIngestJobs: { increment: 1 }, + organization: { + update: { + totalIngestJobs: { increment: 1 }, + }, }, }, - }, - select: { id: true }, - }), - ]); + select: { id: true }, + }), + ]); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new AgentsetApiError({ + code: "conflict", + message: `The external ID "${data.externalId}" is already in use.`, + }); + } + + throw error; + } const handle = await triggerIngestionJob( { jobId: job.id, - organizationId, + organizationId: organization.id, }, - plan, + organization.plan, ); await db.ingestJob.update({ @@ -183,7 +220,7 @@ export const createIngestJob = async ({ trigger: "ingest_job.queued", ingestJob: { ...job, - organizationId, + organizationId: organization.id, }, }), ); diff --git a/apps/web/src/services/ingest-jobs/delete.ts b/apps/web/src/services/ingest-jobs/delete.ts index 0adad3f4..20e1553a 100644 --- a/apps/web/src/services/ingest-jobs/delete.ts +++ b/apps/web/src/services/ingest-jobs/delete.ts @@ -1,3 +1,4 @@ +import { AgentsetApiError } from "@/lib/api/errors"; import { emitIngestJobWebhook } from "@/lib/webhook/emit"; import { waitUntil } from "@vercel/functions"; @@ -7,13 +8,40 @@ import { triggerDeleteIngestJob } from "@agentset/jobs"; export const deleteIngestJob = async ({ jobId, + namespaceId, organizationId, }: { jobId: string; + namespaceId: string; organizationId: string; }) => { + const ingestJob = await db.ingestJob.findUnique({ + where: { + id: jobId, + namespaceId, + }, + select: { id: true, status: true }, + }); + + if (!ingestJob) { + throw new AgentsetApiError({ + code: "not_found", + message: "Ingest job not found", + }); + } + + if ( + ingestJob.status === IngestJobStatus.QUEUED_FOR_DELETE || + ingestJob.status === IngestJobStatus.DELETING + ) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Ingest job is already being deleted", + }); + } + const job = await db.ingestJob.update({ - where: { id: jobId }, + where: { id: ingestJob.id }, data: { status: IngestJobStatus.QUEUED_FOR_DELETE, }, diff --git a/apps/web/src/services/ingest-jobs/re-ingest.ts b/apps/web/src/services/ingest-jobs/re-ingest.ts new file mode 100644 index 00000000..8a05c069 --- /dev/null +++ b/apps/web/src/services/ingest-jobs/re-ingest.ts @@ -0,0 +1,100 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { emitIngestJobWebhook } from "@/lib/webhook/emit"; +import { waitUntil } from "@vercel/functions"; + +import { IngestJobStatus } from "@agentset/db"; +import { db } from "@agentset/db/client"; +import { triggerReIngestJob } from "@agentset/jobs"; + +export const reIngestJob = async ({ + jobId, + namespaceId, + organizationId, + plan, +}: { + jobId: string; + namespaceId: string; + organizationId: string; + /** The organization's plan. When omitted, it's fetched in parallel with the job lookup. */ + plan?: string; +}) => { + const [ingestJob, resolvedPlan] = await Promise.all([ + db.ingestJob.findUnique({ + where: { + id: jobId, + namespaceId, + }, + select: { id: true, status: true }, + }), + plan ?? + db.organization + .findUnique({ + where: { id: organizationId }, + select: { plan: true }, + }) + .then((organization) => organization?.plan), + ]); + + if (!ingestJob) { + throw new AgentsetApiError({ + code: "not_found", + message: "Ingest job not found", + }); + } + + if (!resolvedPlan) { + throw new AgentsetApiError({ + code: "not_found", + message: "Organization not found", + }); + } + + if ( + ingestJob.status === IngestJobStatus.QUEUED_FOR_DELETE || + ingestJob.status === IngestJobStatus.DELETING + ) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Ingest job is being deleted", + }); + } + + if ( + ingestJob.status === IngestJobStatus.PRE_PROCESSING || + ingestJob.status === IngestJobStatus.PROCESSING + ) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Ingest job is already being processed", + }); + } + + const handle = await triggerReIngestJob( + { + jobId: ingestJob.id, + }, + resolvedPlan, + ); + + const job = await db.ingestJob.update({ + where: { id: ingestJob.id }, + data: { + status: IngestJobStatus.QUEUED_FOR_RESYNC, + queuedAt: new Date(), + workflowRunsIds: { push: handle.id }, + }, + }); + + // Emit ingest_job.queued_for_resync webhook + waitUntil( + emitIngestJobWebhook({ + trigger: "ingest_job.queued_for_resync", + ingestJob: { + ...job, + organizationId, + }, + }), + ); + + return job; +}; diff --git a/apps/web/src/services/namespaces/create.ts b/apps/web/src/services/namespaces/create.ts index 6842537b..754a9433 100644 --- a/apps/web/src/services/namespaces/create.ts +++ b/apps/web/src/services/namespaces/create.ts @@ -1,17 +1,66 @@ +import type { createNamespaceSchema } from "@/schemas/api/namespace"; +import type { z } from "zod/v4"; +import { AgentsetApiError } from "@/lib/api/errors"; + +import { Prisma } from "@agentset/db"; import { db } from "@agentset/db/client"; +import { validateEmbeddingModel, validateVectorStoreConfig } from "./validate"; + export const createNamespace = async ({ - name, organizationId, - slug, + data, }: { - name: string; - slug: string; organizationId: string; + data: z.infer; }) => { - const namespace = await db.namespace.create({ - data: { name, slug, organizationId }, - }); + const { success: isValidEmbedding, error: embeddingError } = + await validateEmbeddingModel(data.embeddingConfig); + if (!isValidEmbedding) { + throw new AgentsetApiError({ + code: "bad_request", + message: embeddingError, + }); + } + + const { success: isValidVectorStore, error: vectorStoreError } = + await validateVectorStoreConfig(data.vectorStoreConfig, data.embeddingConfig); + if (!isValidVectorStore) { + throw new AgentsetApiError({ + code: "bad_request", + message: vectorStoreError, + }); + } + + try { + const [namespace] = await db.$transaction([ + db.namespace.create({ + data: { + name: data.name, + slug: data.slug, + organizationId, + embeddingConfig: data.embeddingConfig, + vectorStoreConfig: data.vectorStoreConfig, + }, + }), + db.organization.update({ + where: { id: organizationId }, + data: { totalNamespaces: { increment: 1 } }, + }), + ]); + + return namespace; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new AgentsetApiError({ + code: "conflict", + message: `The slug "${data.slug}" is already in use.`, + }); + } - return namespace; + throw error; + } }; diff --git a/apps/web/src/services/namespaces/delete.ts b/apps/web/src/services/namespaces/delete.ts index 38b95fdc..d27c7616 100644 --- a/apps/web/src/services/namespaces/delete.ts +++ b/apps/web/src/services/namespaces/delete.ts @@ -1,3 +1,5 @@ +import { revalidateTag } from "next/cache"; + import { NamespaceStatus } from "@agentset/db"; import { db } from "@agentset/db/client"; import { triggerDeleteNamespace } from "@agentset/jobs"; @@ -12,6 +14,8 @@ export const deleteNamespace = async ({ data: { status: NamespaceStatus.DELETING }, }); + revalidateTag(`ns:${namespaceId}`, "max"); + await triggerDeleteNamespace({ namespaceId, }); diff --git a/apps/web/src/services/namespaces/update.ts b/apps/web/src/services/namespaces/update.ts new file mode 100644 index 00000000..1418fdbd --- /dev/null +++ b/apps/web/src/services/namespaces/update.ts @@ -0,0 +1,48 @@ +import type { updateNamespaceSchema } from "@/schemas/api/namespace"; +import type { z } from "zod/v4"; +import { revalidateTag } from "next/cache"; +import { AgentsetApiError } from "@/lib/api/errors"; + +import { Prisma } from "@agentset/db"; +import { db } from "@agentset/db/client"; + +export const updateNamespace = async ({ + namespaceId, + organizationId, + data, +}: { + namespaceId: string; + organizationId: string; + data: z.infer; +}) => { + const { name, slug } = data; + + try { + const namespace = await db.namespace.update({ + where: { + id: namespaceId, + }, + data: { + ...(name && { name }), + ...(slug && { slug }), + }, + }); + + revalidateTag(`ns:${namespaceId}`, "max"); + revalidateTag(`org:${organizationId}`, "max"); + + return namespace; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new AgentsetApiError({ + code: "conflict", + message: `The slug "${slug}" is already in use.`, + }); + } + + throw error; + } +}; diff --git a/apps/web/src/services/organizations/get.ts b/apps/web/src/services/organizations/get.ts new file mode 100644 index 00000000..593e37e2 --- /dev/null +++ b/apps/web/src/services/organizations/get.ts @@ -0,0 +1,49 @@ +import { OrganizationSchema } from "@/schemas/api/organization"; + +import { db } from "@agentset/db/client"; +import { prefixId } from "@agentset/utils"; + +export const getOrganization = async ({ + organizationId, +}: { + organizationId: string; +}) => { + return await db.organization.findUnique({ + where: { + id: organizationId, + }, + select: { + id: true, + name: true, + slug: true, + plan: true, + createdAt: true, + searchUsage: true, + searchLimit: true, + totalPages: true, + pagesLimit: true, + apiRatelimit: true, + }, + }); +}; + +export type OrganizationInfo = NonNullable< + Awaited> +>; + +// the curated public shape shared by GET/PATCH /v1/organization and the MCP tools +export const toOrganizationResponse = (organization: OrganizationInfo) => + OrganizationSchema.parse({ + id: prefixId(organization.id, "org_"), + name: organization.name, + slug: organization.slug, + plan: organization.plan, + createdAt: organization.createdAt, + usage: { + searchUsage: organization.searchUsage, + searchLimit: organization.searchLimit, + totalPages: organization.totalPages, + pagesLimit: organization.pagesLimit, + apiRatelimit: organization.apiRatelimit, + }, + }); diff --git a/apps/web/src/services/organizations/members.ts b/apps/web/src/services/organizations/members.ts new file mode 100644 index 00000000..90eee6ab --- /dev/null +++ b/apps/web/src/services/organizations/members.ts @@ -0,0 +1,44 @@ +import { db } from "@agentset/db/client"; + +export const getOrganizationMembers = async ({ + organizationId, + userId, +}: { + organizationId: string; + // when provided, only returns results if the user is a member of the organization + userId?: string; +}) => { + return await db.organization.findUnique({ + where: { + id: organizationId, + ...(userId && { + members: { + some: { + userId, + }, + }, + }), + }, + select: { + members: { + include: { + user: { + select: { + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }, + invitations: { + where: { + status: "pending", + }, + }, + }, + }); +}; diff --git a/apps/web/src/services/organizations/update.ts b/apps/web/src/services/organizations/update.ts new file mode 100644 index 00000000..21d154d2 --- /dev/null +++ b/apps/web/src/services/organizations/update.ts @@ -0,0 +1,75 @@ +import { revalidateTag } from "next/cache"; +import { AgentsetApiError } from "@/lib/api/errors"; +import { waitUntil } from "@vercel/functions"; + +import { Prisma } from "@agentset/db"; +import { db } from "@agentset/db/client"; + +export const updateOrganization = async ({ + organizationId, + name, + slug, +}: { + organizationId: string; + name?: string; + slug?: string; +}) => { + let organization; + try { + organization = await db.organization.update({ + where: { + id: organizationId, + }, + data: { + ...(name && { name }), + ...(slug && { slug }), + }, + select: { + id: true, + name: true, + slug: true, + plan: true, + createdAt: true, + searchUsage: true, + searchLimit: true, + totalPages: true, + pagesLimit: true, + apiRatelimit: true, + }, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new AgentsetApiError({ + code: "conflict", + message: `The slug "${slug}" is already in use.`, + }); + } + + throw error; + } + + // the api key cache embeds organization fields, so expire it along with the org tag + waitUntil( + (async () => { + revalidateTag(`org:${organizationId}`, "max"); + + const apiKeys = await db.organizationApiKey.findMany({ + where: { + organizationId, + }, + select: { + key: true, + }, + }); + + for (const apiKey of apiKeys) { + revalidateTag(`apiKey:${apiKey.key}`, "max"); + } + })(), + ); + + return organization; +}; diff --git a/apps/web/src/services/search.ts b/apps/web/src/services/search.ts new file mode 100644 index 00000000..e6533ed3 --- /dev/null +++ b/apps/web/src/services/search.ts @@ -0,0 +1,63 @@ +import type { queryVectorStoreSchema } from "@/schemas/api/query"; +import type { z } from "zod/v4"; +import { AgentsetApiError } from "@/lib/api/errors"; + +import type { Namespace } from "@agentset/db"; +import type { QueryVectorStoreResult } from "@agentset/engine"; +import { + getNamespaceEmbeddingModel, + getNamespaceVectorStore, + queryVectorStore, +} from "@agentset/engine"; + +/** + * Runs a public-API search over a namespace's vector store. Shared by the + * REST search route and the MCP search tool so the option mapping (and the + * Pinecone keyword-mode gate) lives in one place. + */ +export const searchNamespace = async ({ + namespace, + tenantId, + options, +}: { + namespace: Pick; + tenantId?: string; + options: z.infer; +}): Promise => { + const isPinecone = + namespace.vectorStoreConfig?.provider === "MANAGED_PINECONE" || + namespace.vectorStoreConfig?.provider === "MANAGED_PINECONE_OLD" || + namespace.vectorStoreConfig?.provider === "PINECONE"; + + if (options.mode === "keyword" && isPinecone) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Keyword search is not enabled for this namespace", + }); + } + + const [embeddingModel, vectorStore] = await Promise.all([ + getNamespaceEmbeddingModel(namespace, "query"), + getNamespaceVectorStore(namespace, tenantId), + ]); + + const { results } = await queryVectorStore({ + embeddingModel, + vectorStore, + query: options.query, + mode: options.mode, + topK: options.topK, + minScore: options.minScore, + filter: options.filter, + includeMetadata: options.includeMetadata, + includeRelationships: options.includeRelationships, + rerank: options.rerank + ? { + model: options.rerankModel, + limit: options.rerankLimit, + } + : false, + }); + + return results; +}; diff --git a/apps/web/src/services/webhooks/create.ts b/apps/web/src/services/webhooks/create.ts new file mode 100644 index 00000000..41728e86 --- /dev/null +++ b/apps/web/src/services/webhooks/create.ts @@ -0,0 +1,22 @@ +import type { z } from "zod/v4"; +import { createWebhook as createWebhookRecord } from "@/lib/webhook/create-webhook"; +import { transformWebhook } from "@/lib/webhook/transform"; +import { validateWebhook } from "@/lib/webhook/validate-webhook"; + +import type { createWebhookSchema } from "@agentset/webhooks"; + +export const createWebhook = async ({ + organizationId, + ...input +}: z.infer & { + organizationId: string; +}) => { + await validateWebhook({ input, organizationId }); + + const webhook = await createWebhookRecord({ + ...input, + organizationId, + }); + + return transformWebhook(webhook); +}; diff --git a/apps/web/src/services/webhooks/delete.ts b/apps/web/src/services/webhooks/delete.ts new file mode 100644 index 00000000..b025b4e3 --- /dev/null +++ b/apps/web/src/services/webhooks/delete.ts @@ -0,0 +1,30 @@ +import { toggleWebhooksForOrganization } from "@/lib/webhook/update-webhook"; + +import { db } from "@agentset/db/client"; + +export const deleteWebhook = async ({ + organizationId, + webhookId, +}: { + organizationId: string; + webhookId: string; +}) => { + const webhook = await db.webhook.findUnique({ + where: { + id: webhookId, + organizationId, + }, + }); + + if (!webhook) return null; + + await db.webhook.delete({ + where: { id: webhookId }, + }); + + await toggleWebhooksForOrganization({ + organizationId, + }); + + return webhook; +}; diff --git a/apps/web/src/services/webhooks/get.ts b/apps/web/src/services/webhooks/get.ts new file mode 100644 index 00000000..49f74893 --- /dev/null +++ b/apps/web/src/services/webhooks/get.ts @@ -0,0 +1,43 @@ +import { transformWebhook } from "@/lib/webhook/transform"; + +import { db } from "@agentset/db/client"; + +export const getWebhook = async ({ + organizationId, + webhookId, +}: { + organizationId: string; + webhookId: string; +}) => { + const webhook = await db.webhook.findUnique({ + where: { + id: webhookId, + organizationId, + }, + select: { + id: true, + name: true, + url: true, + secret: true, + triggers: true, + disabledAt: true, + consecutiveFailures: true, + lastFailedAt: true, + createdAt: true, + namespaces: { + select: { + namespaceId: true, + }, + }, + }, + }); + + if (!webhook) return null; + + return { + ...transformWebhook(webhook), + consecutiveFailures: webhook.consecutiveFailures, + lastFailedAt: webhook.lastFailedAt, + createdAt: webhook.createdAt, + }; +}; diff --git a/apps/web/src/services/webhooks/list.ts b/apps/web/src/services/webhooks/list.ts new file mode 100644 index 00000000..d141833d --- /dev/null +++ b/apps/web/src/services/webhooks/list.ts @@ -0,0 +1,12 @@ +import { getWebhooks } from "@/lib/webhook/get-webhooks"; +import { transformWebhook } from "@/lib/webhook/transform"; + +export const listWebhooks = async ({ + organizationId, +}: { + organizationId: string; +}) => { + const webhooks = await getWebhooks({ organizationId }); + + return webhooks.map(transformWebhook); +}; diff --git a/apps/web/src/services/webhooks/plan.ts b/apps/web/src/services/webhooks/plan.ts new file mode 100644 index 00000000..b0e7225b --- /dev/null +++ b/apps/web/src/services/webhooks/plan.ts @@ -0,0 +1,14 @@ +import { AgentsetApiError } from "@/lib/api/errors"; + +import { isFreePlan } from "@agentset/stripe/plans"; + +// Webhooks are only available on the pro plan and above +export const requireWebhooksPlan = (plan: string) => { + if (isFreePlan(plan)) { + throw new AgentsetApiError({ + code: "forbidden", + message: + "Webhooks are only available on the Pro plan and above. Please upgrade to use this feature.", + }); + } +}; diff --git a/apps/web/src/services/webhooks/regenerate-secret.ts b/apps/web/src/services/webhooks/regenerate-secret.ts new file mode 100644 index 00000000..dadf8360 --- /dev/null +++ b/apps/web/src/services/webhooks/regenerate-secret.ts @@ -0,0 +1,47 @@ +import { createWebhookSecret } from "@/lib/webhook/secret"; +import { transformWebhook } from "@/lib/webhook/transform"; + +import { db } from "@agentset/db/client"; +import { webhookCache } from "@agentset/webhooks/server"; + +export const regenerateWebhookSecret = async ({ + organizationId, + webhookId, +}: { + organizationId: string; + webhookId: string; +}) => { + const webhook = await db.webhook.findUnique({ + where: { + id: webhookId, + organizationId, + }, + }); + + if (!webhook) return null; + + const newSecret = createWebhookSecret(); + + const updatedWebhook = await db.webhook.update({ + where: { id: webhookId }, + data: { secret: newSecret }, + select: { + id: true, + name: true, + url: true, + secret: true, + triggers: true, + disabledAt: true, + namespaces: { + select: { + namespaceId: true, + }, + }, + }, + }); + + // Invalidate webhook cache + await webhookCache.invalidateOrg(organizationId); + + return transformWebhook(updatedWebhook); +}; diff --git a/apps/web/src/services/webhooks/toggle.ts b/apps/web/src/services/webhooks/toggle.ts new file mode 100644 index 00000000..6dee3c49 --- /dev/null +++ b/apps/web/src/services/webhooks/toggle.ts @@ -0,0 +1,60 @@ +import { transformWebhook } from "@/lib/webhook/transform"; +import { toggleWebhooksForOrganization } from "@/lib/webhook/update-webhook"; + +import { db } from "@agentset/db/client"; + +export const toggleWebhook = async ({ + organizationId, + webhookId, + enabled, +}: { + organizationId: string; + webhookId: string; + // when omitted, the webhook's current state is flipped + enabled?: boolean; +}) => { + const webhook = await db.webhook.findUnique({ + where: { + id: webhookId, + organizationId, + }, + select: { disabledAt: true }, + }); + + if (!webhook) return null; + + const shouldEnable = enabled ?? !!webhook.disabledAt; + const disabledAt = shouldEnable ? null : (webhook.disabledAt ?? new Date()); + + const updatedWebhook = await db.webhook.update({ + where: { id: webhookId }, + data: { + disabledAt, + // Reset failure count when re-enabling + ...(webhook.disabledAt && + shouldEnable && { + consecutiveFailures: 0, + lastFailedAt: null, + }), + }, + select: { + id: true, + name: true, + url: true, + secret: true, + triggers: true, + disabledAt: true, + namespaces: { + select: { + namespaceId: true, + }, + }, + }, + }); + + await toggleWebhooksForOrganization({ + organizationId, + }); + + return transformWebhook(updatedWebhook); +}; diff --git a/apps/web/src/services/webhooks/update.ts b/apps/web/src/services/webhooks/update.ts new file mode 100644 index 00000000..745bdf6e --- /dev/null +++ b/apps/web/src/services/webhooks/update.ts @@ -0,0 +1,145 @@ +import type { z } from "zod/v4"; +import { AgentsetApiError } from "@/lib/api/errors"; +import { transformWebhook } from "@/lib/webhook/transform"; +import { validateWebhook } from "@/lib/webhook/validate-webhook"; + +import type { updateWebhookSchema, WebhookProps } from "@agentset/webhooks"; +import { db } from "@agentset/db/client"; +import { normalizeId } from "@agentset/utils"; +import { webhookCache } from "@agentset/webhooks/server"; + +import { requireWebhooksPlan } from "./plan"; +import { toggleWebhook } from "./toggle"; + +export const updateWebhook = async ({ + organizationId, + webhookId, + ...input +}: z.infer & { + organizationId: string; + webhookId: string; +}) => { + const existingWebhook = await db.webhook.findUnique({ + where: { + id: webhookId, + organizationId, + }, + }); + + if (!existingWebhook) return null; + + await validateWebhook({ + input, + organizationId, + webhook: existingWebhook, + }); + + const { name, url, triggers, namespaceIds } = input; + + const webhook = await db.webhook.update({ + where: { + id: webhookId, + organizationId, + }, + data: { + ...(name && { name }), + ...(url && { url }), + ...(triggers && { triggers }), + ...(namespaceIds !== undefined && { + namespaces: { + deleteMany: {}, + ...(namespaceIds.length > 0 && { + create: namespaceIds.map((namespaceId) => ({ + namespaceId, + })), + }), + }, + }), + }, + select: { + id: true, + name: true, + url: true, + secret: true, + triggers: true, + disabledAt: true, + namespaces: { + select: { + namespaceId: true, + }, + }, + }, + }); + + // Invalidate webhook cache + await webhookCache.invalidateOrg(organizationId); + + return transformWebhook(webhook); +}; + +/** + * Shared PATCH orchestration for REST and MCP: field updates are pro-gated, + * toggling `enabled` alone is not. + */ +export const applyWebhookUpdate = async ({ + organizationId, + plan, + webhookId, + enabled, + ...input +}: z.infer & { + organizationId: string; + plan: string; + webhookId: string; + enabled?: boolean; +}): Promise => { + const hasUpdates = + input.name !== undefined || + input.url !== undefined || + input.triggers !== undefined || + input.namespaceIds !== undefined; + + if (!hasUpdates && enabled === undefined) { + throw new AgentsetApiError({ + code: "bad_request", + message: "At least one field must be provided.", + }); + } + + const notFoundError = () => + new AgentsetApiError({ + code: "not_found", + message: "Webhook not found.", + }); + + let webhook: WebhookProps | null = null; + + if (hasUpdates) { + requireWebhooksPlan(plan); + + webhook = await updateWebhook({ + organizationId, + webhookId, + ...input, + namespaceIds: input.namespaceIds?.map((id) => normalizeId(id, "ns_")), + }); + + if (!webhook) { + throw notFoundError(); + } + } + + if (enabled !== undefined) { + webhook = await toggleWebhook({ + organizationId, + webhookId, + enabled, + }); + } + + if (!webhook) { + throw notFoundError(); + } + + return webhook; +}; diff --git a/bun.lock b/bun.lock index 2df8e032..a0791b98 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@calcom/embed-react": "^1.5.3", "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^5.2.2", + "@modelcontextprotocol/sdk": "1.26.0", "@number-flow/react": "^0.5.10", "@t3-oss/env-nextjs": "catalog:", "@tanstack/react-query": "catalog:", @@ -48,6 +49,7 @@ "fast-deep-equal": "^3.1.3", "file-type": "^21.3.0", "jiti": "^2.6.1", + "mcp-handler": "^1.1.0", "motion": "^12.26.2", "next": "^16.1.6", "posthog-js": "^1.326.0", @@ -872,7 +874,7 @@ "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.24.3", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], @@ -1180,6 +1182,18 @@ "@react-email/text": ["@react-email/text@0.1.5", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg=="], + "@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="], + + "@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="], + + "@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="], + + "@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="], + + "@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="], + + "@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="], @@ -1628,7 +1642,7 @@ "bintrees": ["bintrees@1.0.2", "", {}, "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="], - "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "3.1.2", "content-type": "1.0.5", "debug": "4.4.1", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "on-finished": "2.4.1", "qs": "6.14.0", "raw-body": "3.0.0", "type-is": "2.0.1" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -1706,6 +1720,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "react": "19.2.1", "react-dom": "19.2.1" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], @@ -1738,7 +1754,7 @@ "convict": ["convict@6.2.4", "", { "dependencies": { "lodash.clonedeep": "4.5.0", "yargs-parser": "20.2.9" } }, "sha512-qN60BAwdMVdofckX7AlohVJ2x9UvjTNoKVXCL2LxFk1l7757EJqf1nySdMkPQer0bt8kQ5lQiyZ9/2NvrFBuwQ=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -1956,9 +1972,9 @@ "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], - "express": ["express@5.1.0", "", { "dependencies": { "accepts": "2.0.0", "body-parser": "2.2.0", "content-disposition": "1.0.0", "content-type": "1.0.5", "cookie": "0.7.2", "cookie-signature": "1.2.2", "debug": "4.4.1", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "finalhandler": "2.1.0", "fresh": "2.0.0", "http-errors": "2.0.0", "merge-descriptors": "2.0.0", "mime-types": "3.0.1", "on-finished": "2.4.1", "once": "1.4.0", "parseurl": "1.3.3", "proxy-addr": "2.0.7", "qs": "6.14.0", "range-parser": "1.2.1", "router": "2.2.0", "send": "1.2.0", "serve-static": "2.2.0", "statuses": "2.0.2", "type-is": "2.0.1", "vary": "1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": "5.1.0" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], @@ -2044,6 +2060,8 @@ "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + "generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -2140,7 +2158,7 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "0.11.0", "deepmerge": "4.3.1", "dom-serializer": "2.0.0", "htmlparser2": "8.0.2", "selderee": "0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], @@ -2416,6 +2434,8 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mcp-handler": ["mcp-handler@1.1.0", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^11.1.0", "redis": "^4.6.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "1.26.0", "next": ">=13.0.0" }, "optionalPeers": ["next"], "bin": { "mcp-adapter": "dist/cli/index.js", "mcp-handler": "dist/cli/index.js", "create-mcp-route": "dist/cli/index.js" } }, "sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "4.0.4", "escape-string-regexp": "5.0.0", "unist-util-is": "6.0.0", "unist-util-visit-parents": "6.0.1" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "decode-named-character-reference": "1.1.0", "devlop": "1.1.0", "mdast-util-to-string": "4.0.0", "micromark": "4.0.2", "micromark-util-decode-numeric-character-reference": "2.0.2", "micromark-util-decode-string": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2", "unist-util-stringify-position": "4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], @@ -2800,6 +2820,8 @@ "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "1.0.8", "define-properties": "1.2.1", "es-abstract": "1.23.9", "es-errors": "1.3.0", "es-object-atoms": "1.1.1", "get-intrinsic": "1.3.0", "get-proto": "1.0.1", "which-builtin-type": "1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], @@ -3494,9 +3516,7 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], - - "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], + "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], @@ -3524,6 +3544,8 @@ "@prisma/config/c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "4.0.3", "confbox": "0.2.2", "defu": "6.1.4", "dotenv": "16.6.1", "exsolve": "1.0.7", "giget": "2.0.0", "jiti": "2.6.1", "ohash": "2.0.11", "pathe": "2.0.3", "perfect-debounce": "1.0.0", "pkg-types": "2.3.0", "rc9": "2.1.2" }, "optionalDependencies": { "magicast": "0.3.5" } }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.1", "", { "dependencies": { "@prisma/debug": "7.4.1" } }, "sha512-kN4tmkQzlgm/KtE+jTNSYjsDxxe/5i6GApPI32BN9T0tlgsgSBtDJbjGBICttkAIjsh73dXf8raPKxO/2n2UUg=="], @@ -3654,6 +3676,8 @@ "@react-email/tailwind/tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], + "@redis/client/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "@smithy/core/@smithy/protocol-http": ["@smithy/protocol-http@5.0.1", "", { "dependencies": { "@smithy/types": "4.1.0", "tslib": "2.8.1" } }, "sha512-TE4cpj49jJNB/oHyh/cRVEgNZaoPaxd4vteJNB0yGidOCVR0jCw/hjPVsT8Q8FRmj8Bd3bFZt8Dh7xGCT+xMBQ=="], "@smithy/eventstream-codec/@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.0.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw=="], @@ -3818,7 +3842,11 @@ "better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], - "body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "body-parser/qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "body-parser/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="], @@ -3856,8 +3884,6 @@ "engine.io/@types/node": ["@types/node@22.18.4", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw=="], - "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "2.1.3" }, "optionalDependencies": { "supports-color": "10.0.0" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "engine.io/ws": ["ws@8.17.1", "", {}, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], @@ -3904,7 +3930,7 @@ "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.1", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3936,12 +3962,18 @@ "magicast/@babel/types": ["@babel/types@7.26.10", "", { "dependencies": { "@babel/helper-string-parser": "7.25.9", "@babel/helper-validator-identifier": "7.25.9" } }, "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ=="], + "mcp-handler/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + + "mcp-handler/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "micromark-extension-math/katex": ["katex@0.16.22", "", { "dependencies": { "commander": "8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "msw/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next-themes/react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], @@ -4016,8 +4048,6 @@ "shadcn/@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "shadcn/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], - "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "shadcn/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "@nodelib/fs.walk": "1.2.8", "glob-parent": "5.1.2", "merge2": "1.4.1", "micromatch": "4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -4068,6 +4098,8 @@ "swr/react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], + "trigger.dev/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.24.3", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw=="], + "trigger.dev/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], "trigger.dev/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -4482,6 +4514,8 @@ "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "body-parser/raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "c12/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "c12/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "2.3.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -4612,18 +4646,6 @@ "shadcn/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "shadcn/@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - - "shadcn/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "shadcn/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], - - "shadcn/@modelcontextprotocol/sdk/hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], - - "shadcn/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "shadcn/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "shadcn/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "shadcn/open/default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -4634,6 +4656,16 @@ "stripe/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "trigger.dev/@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "trigger.dev/@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "2.0.0", "body-parser": "2.2.0", "content-disposition": "1.0.0", "content-type": "1.0.5", "cookie": "0.7.2", "cookie-signature": "1.2.2", "debug": "4.4.1", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "finalhandler": "2.1.0", "fresh": "2.0.0", "http-errors": "2.0.0", "merge-descriptors": "2.0.0", "mime-types": "3.0.1", "on-finished": "2.4.1", "once": "1.4.0", "parseurl": "1.3.3", "proxy-addr": "2.0.7", "qs": "6.14.0", "range-parser": "1.2.1", "router": "2.2.0", "send": "1.2.0", "serve-static": "2.2.0", "statuses": "2.0.2", "type-is": "2.0.1", "vary": "1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + + "trigger.dev/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": "5.1.0" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + + "trigger.dev/@modelcontextprotocol/sdk/zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], + + "trigger.dev/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], + "trigger.dev/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "trigger.dev/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "2.3.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -4854,15 +4886,11 @@ "shadcn/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - "shadcn/@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "trigger.dev/@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "shadcn/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.1", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "trigger.dev/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.1", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "shadcn/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "shadcn/@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "shadcn/@modelcontextprotocol/sdk/express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "trigger.dev/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "3.1.2", "content-type": "1.0.5", "debug": "4.4.1", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "on-finished": "2.4.1", "qs": "6.14.0", "raw-body": "3.0.0", "type-is": "2.0.1" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], "trigger.dev/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -4874,14 +4902,10 @@ "shadcn/@babel/core/@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "shadcn/@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "trigger.dev/@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "shadcn/@modelcontextprotocol/sdk/express/body-parser/qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], - - "shadcn/@modelcontextprotocol/sdk/express/body-parser/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "trigger.dev/@modelcontextprotocol/sdk/express/body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "@trigger.dev/build/@prisma/config/c12/giget/nypm/tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], - - "shadcn/@modelcontextprotocol/sdk/express/body-parser/raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], } } diff --git a/internal-docs/analytics-events.md b/internal-docs/analytics-events.md deleted file mode 100644 index dca3d3a1..00000000 --- a/internal-docs/analytics-events.md +++ /dev/null @@ -1,546 +0,0 @@ -# Analytics Events Documentation - -This document describes all the analytics events tracked throughout the Agentset application using PostHog. Each event captures specific user actions and includes relevant context data. - -## Authentication Events - -### `auth_magic_link_sent` - -Triggered when a user requests a magic link for email authentication. - -**Payload:** - -```typescript -{ - email: string; // The email address used for authentication -} -``` - -**Location:** `apps/web/src/hooks/use-auth.ts` (via `useMagicAuth` hook) - -### `auth_social_login` - -Triggered when a user initiates social login (Google or GitHub). - -**Payload:** - -```typescript -{ - provider: "google" | "github"; // The social auth provider used -} -``` - -**Location:** `apps/web/src/hooks/use-auth.ts` (via `useGoogleAuth` and `useGithubAuth` hooks) - -## Organization Management Events - -### `organization_created` - -Triggered when a new organization is successfully created. - -**Payload:** - -```typescript -{ - slug: string; // Organization slug - name: string; // Organization name -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/create-organization/create-org-form.tsx` - -### `organization_updated` - -Triggered when organization settings are updated. - -**Payload:** - -```typescript -{ - id: string; // Organization ID - nameChanged: boolean; // Whether the name was changed - slugChanged: boolean; // Whether the slug was changed -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/page.client.tsx` - -## Namespace Management Events - -### `namespace_created` - -Triggered when a new namespace is successfully created. - -**Payload:** - -```typescript -{ - name: string; // Namespace name - slug: string; // Namespace slug - organizationId: string; // Parent organization ID - embeddingModel: { // Embedding configuration (if set) - provider: string; // e.g., "openai", "cohere" - model: string; // e.g., "text-embedding-3-small" - } | null; - vectorStore: { // Vector store configuration (if set) - provider: string; // e.g., "pinecone", "qdrant" - } | null; -} -``` - -**Location:** `apps/web/src/components/create-namespace/index.tsx` - -### `namespace_deleted` - -Triggered when a namespace is successfully deleted. - -**Payload:** - -```typescript -{ - id: string; // Namespace ID - name: string; // Namespace name - organizationId: string; // Parent organization ID -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx` - -## Document Management Events - -### `document_ingested` - -Triggered when documents are successfully ingested into a namespace. This event covers text, file uploads, and URL ingestion. - -**Payload:** - -```typescript -{ - type: "text" | "files" | "urls"; // Type of ingestion - namespaceId: string; // Target namespace ID - fileCount?: number; // Number of files (for file uploads) - urlCount?: number; // Number of URLs (for URL ingestion) - chunkSize?: number; // Chunk size configuration - hasMetadata: boolean; // Whether custom metadata was provided -} -``` - -**Locations:** - -- `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/text-form.tsx` -- `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/upload-form.tsx` -- `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/urls-form.tsx` - -### `document_deleted` - -Triggered when a document is successfully deleted. - -**Payload:** - -```typescript -{ - documentId: string; // Document ID - namespaceId: string; // Namespace ID - status: string; // Document status at time of deletion -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx` - -## Chat and Conversation Events - -### `chat_message_sent` - -Triggered when a user sends a message in either the playground or hosted chat. - -**Payload:** - -```typescript -{ - type: "playground" | "hosted"; // Chat interface type - messageLength: number; // Length of the message in characters - hasExistingMessages: boolean; // Whether there were previous messages in the conversation -} -``` - -**Location:** `apps/web/src/components/chat/chat-input.tsx` - -### `chat_reset` - -Triggered when a user resets/clears the chat conversation. - -**Payload:** - -```typescript -{ - type: "playground" | "hosted"; // Chat interface type -} -``` - -**Locations:** - -- `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/chat-actions.tsx` -- `apps/web/src/app/[hostingId]/(defaultLayout)/header.tsx` - -### `chat_suggested_action_clicked` - -Triggered when a user clicks on a suggested action/example question. - -**Payload:** - -```typescript -{ - position: number; // Position/index of the clicked suggestion -} -``` - -**Location:** `apps/web/src/components/chat/suggested-actions.tsx` - -## API Management Events - -### `api_key_created` - -Triggered when a new API key is successfully created. - -**Payload:** - -```typescript -{ - orgId: string; // Organization ID - scope: "all" | string; // API key scope -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx` - -### `api_request` - -Triggered on the server when API requests are made to the public API endpoints. This event captures usage patterns and helps track API adoption. - -**Payload:** - -```typescript -{ - routeName: string; // Descriptive route name (e.g., "GET /v1/namespace", "POST /v1/namespace/[namespaceId]/search") - pathname: string; // Full request pathname - method: string; // HTTP method (GET, POST, PATCH, DELETE, etc.) - statusCode: number; // HTTP response status code - tenantId?: string; // Tenant identifier (if applicable) - namespaceId?: string; // Namespace ID (for namespace-specific endpoints) -} -``` - -**Locations:** - -- `apps/web/src/lib/api/handler/base.ts` (via `withApiHandler`) -- `apps/web/src/lib/api/handler/namespace.ts` (via `withNamespaceApiHandler`) - -**Coverage:** All public API endpoints under `/api/(public-api)/v1/` - -## Team Management Events - -### `team_invite_member_clicked` - -Triggered when a user clicks to invite a new member to their organization. - -**Payload:** - -```typescript -{ - organizationId: string; // Organization ID - email: string; // Email address of the invited member - role: string; // Role being assigned ("admin" | "member") -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/invite-dialog.tsx` - -### `team_accept_invitation` - -Triggered when a user accepts an organization invitation. - -**Payload:** - -```typescript -{ - invitationId: string; // Invitation ID - organizationId: string; // Organization ID - role: string; // Role assigned to the member - email: string; // Email address of the invited member -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/invitation/[id]/invitation-card.tsx` - -### `team_reject_invitation` - -Triggered when a user rejects an organization invitation. - -**Payload:** - -```typescript -{ - invitationId: string; // Invitation ID - organizationId: string; // Organization ID - role: string; // Role that was offered - email: string; // Email address of the invited member -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/invitation/[id]/invitation-card.tsx` - -### `team_revoke_invitation` - -Triggered when an admin revokes a pending invitation. - -**Payload:** - -```typescript -{ - organizationId: string; // Organization ID - invitationId: string; // Invitation ID being revoked -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/revoke-invitation.tsx` - -### `team_remove_member` - -Triggered when a member is removed from an organization (or leaves). - -**Payload:** - -```typescript -{ - organizationId: string; // Organization ID - memberId: string; // Member ID being removed - isCurrentMember: boolean; // Whether the current user is removing themselves -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/remove-member.tsx` - -### `organization_team_role_updated` - -Triggered when a member's role is updated in an organization. - -**Payload:** - -```typescript -{ - organizationId: string; // Organization ID - memberId: string; // Member ID being updated - oldRole: string; // Previous role - newRole: string; // New role -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/member-card.tsx` - -## Domain Management Events - -### `domain_added` - -Triggered when a custom domain is added to a hosting configuration. - -**Payload:** - -```typescript -{ - domain: string; // Domain name added - namespaceId: string; // Namespace ID -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx` - -### `domain_removed` - -Triggered when a custom domain is removed from a hosting configuration. - -**Payload:** - -```typescript -{ - domain: string; // Domain name removed - namespaceId: string; // Namespace ID -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx` - -## Search and Exploration Events - -### `playground_search_performed` - -Triggered when a user performs a search in the playground search interface. - -**Payload:** - -```typescript -{ - namespaceId: string; // Namespace ID where search was performed - query: string; // Search query entered -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx` - -## Billing Events - -### `billing_upgrade_clicked` - -Triggered when a user clicks to upgrade their billing plan. - -**Payload:** - -```typescript -{ - organizationId: string; // Organization ID - plan: string; // Plan being upgraded to - period: "monthly" | "yearly"; // Billing period selected -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/upgrade/upgrade-button.tsx` - -## Feature Access Events - -### `connectors_get_access_clicked` - -Triggered when a user clicks to get access to the connectors feature. - -**Payload:** None (empty object) - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/connectors/empty-state.tsx` - -## Hosting Configuration Events - -### `hosting_enabled` - -Triggered when hosting is first enabled for a namespace. - -**Payload:** - -```typescript -{ - namespaceId: string; // Namespace ID where hosting was enabled -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx` - -### `hosting_search` - -Triggered when a user performs a search in hosted chat. - -**Payload:** - -```typescript -{ - query: string; // The search query entered by the user -} -``` - -**Location:** `apps/web/src/app/[hostingId]/(defaultLayout)/search/use-search.ts` - -### `hosting_search_example` - -Triggered when a user clicks on an example search query in hosted chat. - -**Payload:** None (empty object) - -**Location:** `apps/web/src/app/[hostingId]/(defaultLayout)/search/use-search.ts` - -### `hosting_updated` - -Triggered when hosting configuration for a namespace is updated. - -**Payload:** - -```typescript -{ - namespaceId: string; // Namespace ID - slug: string; // Hosting slug - protected: boolean; // Whether hosting is password protected - searchEnabled: boolean; // Whether search functionality is enabled - hasCustomPrompt: boolean; // Whether a custom system prompt is set - hasWelcomeMessage: boolean; // Whether a welcome message is set - exampleQuestionsCount: number; // Number of example questions configured - exampleSearchQueriesCount: number; // Number of example search queries configured -} -``` - -**Location:** `apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx` - -## Event Usage Guidelines - -### Implementation Principles - -1. **Hybrid Logging**: Events are logged both client-side and server-side depending on context - - **Client-Side**: User interface interactions, feature usage, and user-initiated actions - - **Server-Side**: API usage, system events, and programmatic interactions -2. **Core Actions Only**: We log meaningful user actions, not every button click -3. **Privacy Conscious**: We avoid logging sensitive data like email content, passwords, or document content -4. **Structured Data**: All payloads use consistent TypeScript interfaces -5. **Context Rich**: Events include relevant context for understanding user behavior - -### Client-Side vs Server-Side Logging - -#### Client-Side Events - -- **Location**: Browser/client applications -- **Implementation**: Using `logEvent()` function from `@/lib/analytics` -- **Purpose**: Track user interactions, feature adoption, and UI behavior -- **Examples**: Button clicks, form submissions, navigation, feature usage -- **User Context**: Associated with authenticated user sessions - -#### Server-Side Events - -- **Location**: API handlers and server functions -- **Implementation**: Using `logServerEvent()` function from `@/lib/analytics-server` -- **Purpose**: Track API usage, system performance, and programmatic interactions -- **Examples**: API requests, system operations, background processes -- **User Context**: Associated with API keys and organization identifiers - -Both logging approaches use PostHog as the analytics platform but serve different purposes in understanding user behavior and system usage. - -### Event Naming Convention - -Events follow a `{object}_{action}` pattern: - -- `auth_*` - Authentication related events -- `organization_*` - Organization management events -- `namespace_*` - Namespace management events -- `document_*` - Document management events -- `chat_*` - Chat and conversation events -- `api_*` - API management events -- `team_*` - Team and invitation management events -- `domain_*` - Custom domain management events -- `hosting_*` - Hosting configuration events -- `playground_*` - Playground feature events -- `billing_*` - Billing and subscription events -- `connectors_*` - Connectors and integration events - -### Adding New Events - -#### Client-Side Events - -1. Import the `logEvent` function: `import { logEvent } from "@/lib/analytics";` -2. Call it with a descriptive event name and relevant payload -3. Update this documentation with the new event details -4. Ensure the event is only triggered on successful actions (in `onSuccess` callbacks) - -#### Server-Side Events - -1. Import the server analytics functions: `import { logServerEvent, identifyOrganization, flushServerEvents } from "@/lib/analytics-server";` -2. Use `withApiHandler` or `withNamespaceApiHandler` with logging enabled -3. Events are automatically logged for API requests when logging is configured -4. Custom server events can be logged using `logServerEvent()` directly - -### Analytics Platform - -Events are sent to PostHog for analysis: - -- **Client-side**: `logEvent` function wraps PostHog's client `capture` method (`apps/web/src/lib/analytics.ts`) -- **Server-side**: `logServerEvent` function uses PostHog Node.js client (`apps/web/src/lib/analytics-server.ts`) From f6583893722bdc07eccb7b13fbd443d3a8bd5fd9 Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Thu, 2 Jul 2026 14:03:29 +0200 Subject: [PATCH 02/10] Document the agent-first API surface and hosted MCP server Add OpenAPI-driven reference pages for the new organization, api-keys, webhooks, chat, hosting-domain, and warm-up endpoints. Rewrite the MCP server page hosted-first with client setup for Claude Code, Claude Desktop, and Cursor plus a complete tool table. Add an agent-first concept page. --- .../endpoint/api-keys/create.mdx | 3 + .../endpoint/api-keys/delete.mdx | 3 + docs/api-reference/endpoint/api-keys/list.mdx | 3 + docs/api-reference/endpoint/chat.mdx | 4 + .../endpoint/hosting/domain-add.mdx | 3 + .../endpoint/hosting/domain-remove.mdx | 3 + .../endpoint/hosting/domain-status.mdx | 3 + .../endpoint/namespaces/warm-up.mdx | 3 + .../endpoint/organization/get.mdx | 3 + .../endpoint/organization/members.mdx | 3 + .../endpoint/organization/update.mdx | 3 + .../endpoint/webhooks/create.mdx | 3 + .../endpoint/webhooks/delete.mdx | 3 + docs/api-reference/endpoint/webhooks/get.mdx | 3 + docs/api-reference/endpoint/webhooks/list.mdx | 3 + .../endpoint/webhooks/regenerate-secret.mdx | 3 + .../endpoint/webhooks/update.mdx | 3 + docs/api-reference/introduction.mdx | 2 + docs/docs.json | 40 ++++++++- docs/production/agent-first.mdx | 29 +++++++ docs/production/mcp-server.mdx | 83 ++++++++++++++++--- docs/webhooks/introduction.mdx | 2 +- 22 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 docs/api-reference/endpoint/api-keys/create.mdx create mode 100644 docs/api-reference/endpoint/api-keys/delete.mdx create mode 100644 docs/api-reference/endpoint/api-keys/list.mdx create mode 100644 docs/api-reference/endpoint/chat.mdx create mode 100644 docs/api-reference/endpoint/hosting/domain-add.mdx create mode 100644 docs/api-reference/endpoint/hosting/domain-remove.mdx create mode 100644 docs/api-reference/endpoint/hosting/domain-status.mdx create mode 100644 docs/api-reference/endpoint/namespaces/warm-up.mdx create mode 100644 docs/api-reference/endpoint/organization/get.mdx create mode 100644 docs/api-reference/endpoint/organization/members.mdx create mode 100644 docs/api-reference/endpoint/organization/update.mdx create mode 100644 docs/api-reference/endpoint/webhooks/create.mdx create mode 100644 docs/api-reference/endpoint/webhooks/delete.mdx create mode 100644 docs/api-reference/endpoint/webhooks/get.mdx create mode 100644 docs/api-reference/endpoint/webhooks/list.mdx create mode 100644 docs/api-reference/endpoint/webhooks/regenerate-secret.mdx create mode 100644 docs/api-reference/endpoint/webhooks/update.mdx create mode 100644 docs/production/agent-first.mdx diff --git a/docs/api-reference/endpoint/api-keys/create.mdx b/docs/api-reference/endpoint/api-keys/create.mdx new file mode 100644 index 00000000..7567c23d --- /dev/null +++ b/docs/api-reference/endpoint/api-keys/create.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v1/api-keys +--- diff --git a/docs/api-reference/endpoint/api-keys/delete.mdx b/docs/api-reference/endpoint/api-keys/delete.mdx new file mode 100644 index 00000000..44eb2726 --- /dev/null +++ b/docs/api-reference/endpoint/api-keys/delete.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v1/api-keys/{keyId} +--- diff --git a/docs/api-reference/endpoint/api-keys/list.mdx b/docs/api-reference/endpoint/api-keys/list.mdx new file mode 100644 index 00000000..e3f7da43 --- /dev/null +++ b/docs/api-reference/endpoint/api-keys/list.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v1/api-keys +--- diff --git a/docs/api-reference/endpoint/chat.mdx b/docs/api-reference/endpoint/chat.mdx new file mode 100644 index 00000000..bc3695b6 --- /dev/null +++ b/docs/api-reference/endpoint/chat.mdx @@ -0,0 +1,4 @@ +--- +openapi: post /v1/namespace/{namespaceId}/chat +description: "Generate answers grounded in your documents with normal, agentic, or deep research modes, and optional streaming" +--- diff --git a/docs/api-reference/endpoint/hosting/domain-add.mdx b/docs/api-reference/endpoint/hosting/domain-add.mdx new file mode 100644 index 00000000..a9d0bfb4 --- /dev/null +++ b/docs/api-reference/endpoint/hosting/domain-add.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v1/namespace/{namespaceId}/hosting/domain +--- diff --git a/docs/api-reference/endpoint/hosting/domain-remove.mdx b/docs/api-reference/endpoint/hosting/domain-remove.mdx new file mode 100644 index 00000000..9158492b --- /dev/null +++ b/docs/api-reference/endpoint/hosting/domain-remove.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v1/namespace/{namespaceId}/hosting/domain +--- diff --git a/docs/api-reference/endpoint/hosting/domain-status.mdx b/docs/api-reference/endpoint/hosting/domain-status.mdx new file mode 100644 index 00000000..625cba6f --- /dev/null +++ b/docs/api-reference/endpoint/hosting/domain-status.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v1/namespace/{namespaceId}/hosting/domain +--- diff --git a/docs/api-reference/endpoint/namespaces/warm-up.mdx b/docs/api-reference/endpoint/namespaces/warm-up.mdx new file mode 100644 index 00000000..cb61cc0d --- /dev/null +++ b/docs/api-reference/endpoint/namespaces/warm-up.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v1/namespace/{namespaceId}/warm-up +--- diff --git a/docs/api-reference/endpoint/organization/get.mdx b/docs/api-reference/endpoint/organization/get.mdx new file mode 100644 index 00000000..d630cd37 --- /dev/null +++ b/docs/api-reference/endpoint/organization/get.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v1/organization +--- diff --git a/docs/api-reference/endpoint/organization/members.mdx b/docs/api-reference/endpoint/organization/members.mdx new file mode 100644 index 00000000..3ec0aedb --- /dev/null +++ b/docs/api-reference/endpoint/organization/members.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v1/organization/members +--- diff --git a/docs/api-reference/endpoint/organization/update.mdx b/docs/api-reference/endpoint/organization/update.mdx new file mode 100644 index 00000000..dd833c53 --- /dev/null +++ b/docs/api-reference/endpoint/organization/update.mdx @@ -0,0 +1,3 @@ +--- +openapi: patch /v1/organization +--- diff --git a/docs/api-reference/endpoint/webhooks/create.mdx b/docs/api-reference/endpoint/webhooks/create.mdx new file mode 100644 index 00000000..16f889ef --- /dev/null +++ b/docs/api-reference/endpoint/webhooks/create.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v1/webhooks +--- diff --git a/docs/api-reference/endpoint/webhooks/delete.mdx b/docs/api-reference/endpoint/webhooks/delete.mdx new file mode 100644 index 00000000..b234cd3e --- /dev/null +++ b/docs/api-reference/endpoint/webhooks/delete.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v1/webhooks/{webhookId} +--- diff --git a/docs/api-reference/endpoint/webhooks/get.mdx b/docs/api-reference/endpoint/webhooks/get.mdx new file mode 100644 index 00000000..170615f5 --- /dev/null +++ b/docs/api-reference/endpoint/webhooks/get.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v1/webhooks/{webhookId} +--- diff --git a/docs/api-reference/endpoint/webhooks/list.mdx b/docs/api-reference/endpoint/webhooks/list.mdx new file mode 100644 index 00000000..f25d185a --- /dev/null +++ b/docs/api-reference/endpoint/webhooks/list.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v1/webhooks +--- diff --git a/docs/api-reference/endpoint/webhooks/regenerate-secret.mdx b/docs/api-reference/endpoint/webhooks/regenerate-secret.mdx new file mode 100644 index 00000000..df6aa087 --- /dev/null +++ b/docs/api-reference/endpoint/webhooks/regenerate-secret.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v1/webhooks/{webhookId}/regenerate-secret +--- diff --git a/docs/api-reference/endpoint/webhooks/update.mdx b/docs/api-reference/endpoint/webhooks/update.mdx new file mode 100644 index 00000000..fadf56c7 --- /dev/null +++ b/docs/api-reference/endpoint/webhooks/update.mdx @@ -0,0 +1,3 @@ +--- +openapi: patch /v1/webhooks/{webhookId} +--- diff --git a/docs/api-reference/introduction.mdx b/docs/api-reference/introduction.mdx index ccc6864a..1b269e6b 100644 --- a/docs/api-reference/introduction.mdx +++ b/docs/api-reference/introduction.mdx @@ -4,6 +4,8 @@ title: 'Introduction' description: "Fundamental concepts of Agentset's API." --- +The API covers everything available in the dashboard: organizations, members, API keys, namespaces, uploads, ingest jobs, documents, search, chat, hosting, custom domains, and webhooks. The same surface is also available to AI agents through the [MCP server](/production/mcp-server). + ## Base URL Agentset's API is built on REST principles and is served over HTTPS. To ensure data privacy, unencrypted HTTP is not supported. diff --git a/docs/docs.json b/docs/docs.json index 870232a7..ddbda88a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -67,6 +67,7 @@ "production/hosting-ui", "production/data-segregation", "production/observability", + "production/agent-first", "production/mcp-server" ] }, @@ -131,7 +132,8 @@ "api-reference/endpoint/namespaces/get", "api-reference/endpoint/namespaces/create", "api-reference/endpoint/namespaces/update", - "api-reference/endpoint/namespaces/delete" + "api-reference/endpoint/namespaces/delete", + "api-reference/endpoint/namespaces/warm-up" ] }, { @@ -160,19 +162,53 @@ "api-reference/endpoint/hosting/enable", "api-reference/endpoint/hosting/get", "api-reference/endpoint/hosting/update", - "api-reference/endpoint/hosting/delete" + "api-reference/endpoint/hosting/delete", + "api-reference/endpoint/hosting/domain-add", + "api-reference/endpoint/hosting/domain-status", + "api-reference/endpoint/hosting/domain-remove" ] }, { "group": "Search", "pages": ["api-reference/endpoint/search"] }, + { + "group": "Chat", + "pages": ["api-reference/endpoint/chat"] + }, { "group": "Uploads", "pages": [ "api-reference/endpoint/uploads/single", "api-reference/endpoint/uploads/batch" ] + }, + { + "group": "Webhooks", + "pages": [ + "api-reference/endpoint/webhooks/list", + "api-reference/endpoint/webhooks/get", + "api-reference/endpoint/webhooks/create", + "api-reference/endpoint/webhooks/update", + "api-reference/endpoint/webhooks/delete", + "api-reference/endpoint/webhooks/regenerate-secret" + ] + }, + { + "group": "Organization", + "pages": [ + "api-reference/endpoint/organization/get", + "api-reference/endpoint/organization/update", + "api-reference/endpoint/organization/members" + ] + }, + { + "group": "API Keys", + "pages": [ + "api-reference/endpoint/api-keys/list", + "api-reference/endpoint/api-keys/create", + "api-reference/endpoint/api-keys/delete" + ] } ] }, diff --git a/docs/production/agent-first.mdx b/docs/production/agent-first.mdx new file mode 100644 index 00000000..b503fd5e --- /dev/null +++ b/docs/production/agent-first.mdx @@ -0,0 +1,29 @@ +--- +title: 'Agent-First Platform' +description: 'Everything in the Agentset dashboard is available through the REST API and MCP server, so agents can build and operate RAG stacks autonomously.' +--- + +Everything you can do in the Agentset dashboard is also available programmatically—organizations, members, API keys, namespaces, uploads, ingest jobs, documents, search, chat, hosting, custom domains, and webhooks. That means an agent (or your backend) can operate Agentset end-to-end without a human clicking through the UI. + +There are two surfaces, both authenticated with the same [API keys](/api-reference/tokens): + +- **[REST API](/api-reference/introduction)** — for application code and SDKs. +- **[MCP server](/production/mcp-server)** — for AI agents like Claude and Cursor, with tools that mirror the REST API. + +## Bootstrap a RAG stack autonomously + +A typical end-to-end flow an agent can run on its own: + +1. **Create a namespace** to hold the knowledge base. +2. **Request upload URLs** and upload files, or pass text and URLs directly. +3. **Create an ingest job** and monitor it until documents are ready—or register a [webhook](/webhooks/introduction) to get notified. +4. **Search and chat** over the ingested documents. +5. Optionally **enable hosting** and attach a custom domain to share a chat UI. + +Each step is a single API call or MCP tool invocation, so the whole flow runs without manual setup. + +## Next steps + +- [API reference](/api-reference/introduction) — Base URL, authentication, and every endpoint +- [API keys](/api-reference/tokens) — Create and manage keys for programmatic access +- [MCP server](/production/mcp-server) — Connect agents to the hosted or local server diff --git a/docs/production/mcp-server.mdx b/docs/production/mcp-server.mdx index 0c829d24..3a206ddd 100644 --- a/docs/production/mcp-server.mdx +++ b/docs/production/mcp-server.mdx @@ -1,13 +1,75 @@ --- title: 'MCP Server' -description: 'Documentation for the MCP Server Protocol' +description: 'Connect Claude, Cursor, and other AI agents to Agentset with the hosted MCP server, or run the lightweight local server for search-only access.' --- -The MCP Server Protocol allows you to run a local server that can be used with Claude and other AI assistants to access your data. +Agentset provides two MCP servers: -## Installation +- **Hosted MCP server** — a remote server at `https://api.agentset.ai/mcp` that exposes the full Agentset platform. Agents can create namespaces, ingest data, search, chat, and manage your organization autonomously. +- **Local MCP server** — the [`@agentset/mcp`](https://www.npmjs.com/package/@agentset/mcp) npm package, a lightweight stdio server that searches a single namespace. -Run the Agentset MCP server with your preferred package manager: +## Hosted MCP server + +The hosted server uses streamable HTTP and authenticates with your [API key](/api-reference/tokens) as a bearer token: + +```bash +https://api.agentset.ai/mcp +Authorization: Bearer agentset_xxx +``` + +To scope requests to a single tenant in multi-tenant setups, add an optional `x-tenant-id` header. See [Data Segregation](/production/data-segregation). + +With the full toolset, an agent can bootstrap a RAG stack end-to-end without touching the dashboard: create a namespace, request upload URLs, ingest documents, monitor the ingest job, then search and chat over the results. + +### Claude Code + +```bash +claude mcp add --transport http agentset https://api.agentset.ai/mcp --header 'Authorization: Bearer agentset_xxx' +``` + +### Claude Desktop and claude.ai + +Go to **Settings → Connectors → Add custom connector** and enter `https://api.agentset.ai/mcp` as the server URL. The server authenticates with your API key as a bearer token. + +### Cursor + +Add the server to `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` globally): + +```json +{ + "mcpServers": { + "agentset": { + "url": "https://api.agentset.ai/mcp", + "headers": { + "Authorization": "Bearer agentset_xxx" + } + } + } +} +``` + +### Tools + +Tool names are snake_case and map one-to-one to the [REST API](/api-reference/introduction) operations. Coverage by area: + +| Area | Tools | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Namespaces | `list_namespaces`, `get_namespace`, `create_namespace`, `update_namespace`, `delete_namespace` | +| Uploads | `create_upload_urls` | +| Ingest jobs | `list_ingest_jobs`, `get_ingest_job`, `create_ingest_job`, `reingest_job`, `delete_ingest_job` | +| Documents | `list_documents`, `get_document`, `delete_document`, `get_document_chunks_download_url`, `get_document_file_download_url` | +| Retrieval | `search`, `chat` | +| Hosting | `get_hosting`, `enable_hosting`, `update_hosting`, `disable_hosting` | +| Custom domains | `set_custom_domain`, `get_domain_status`, `remove_custom_domain` | +| Webhooks | `list_webhooks`, `get_webhook`, `create_webhook`, `update_webhook`, `delete_webhook`, `regenerate_webhook_secret` | +| Organization | `get_organization`, `update_organization`, `list_members` | +| API keys | `list_api_keys`, `create_api_key`, `revoke_api_key` | + +## Local MCP server + +The `@agentset/mcp` package runs on your machine over stdio and exposes a single search tool scoped to one namespace. Use it when an agent only needs read access to your data. + +Run it with your preferred package manager: ```bash npm @@ -28,9 +90,9 @@ AGENTSET_API_KEY=your-api-key bunx @agentset/mcp --ns your-namespace-id -## Adding to Claude +### Adding to Claude -To add the MCP server to Claude, include the following configuration in your Claude settings: +To add the local MCP server to Claude, include the following configuration in your Claude settings: ```json { @@ -47,21 +109,21 @@ To add the MCP server to Claude, include the following configuration in your Cla } ``` -## Tips +### Tips -### Passing namespace id as an environment variable +Pass the namespace id as an environment variable: ```bash AGENTSET_API_KEY=your-api-key AGENTSET_NAMESPACE_ID=your-namespace-id npx @agentset/mcp ``` -### Passing a custom tool description +Pass a custom tool description: ```bash AGENTSET_API_KEY=your-api-key npx @agentset/mcp --ns your-namespace-id -d "Your custom tool description" ``` -### Passing a tenant id +Pass a tenant id: ```bash AGENTSET_API_KEY=your-api-key npx @agentset/mcp --ns your-namespace-id -t your-tenant-id @@ -69,5 +131,6 @@ AGENTSET_API_KEY=your-api-key npx @agentset/mcp --ns your-namespace-id -t your-t ## Next steps +- [Agent-First Platform](/production/agent-first) — How the REST API and MCP server cover the entire platform - [Search](/search-and-retrieval/search) — Learn about search configuration options - [Data Segregation](/production/data-segregation) — Use tenant IDs for multi-tenant applications diff --git a/docs/webhooks/introduction.mdx b/docs/webhooks/introduction.mdx index 469921a6..3c069b45 100644 --- a/docs/webhooks/introduction.mdx +++ b/docs/webhooks/introduction.mdx @@ -10,7 +10,7 @@ Webhooks allow you to listen to real-time events happening across your Agentset - Triggering downstream workflows when documents are added or removed - Building dashboards that reflect real-time document processing status -In this guide, you'll learn how to configure webhooks for your Agentset organization and see the list of available events. +In this guide, you'll learn how to configure webhooks for your Agentset organization and see the list of available events. You can also manage webhooks programmatically with the [webhooks API](/api-reference/endpoint/webhooks/list). ## Creating a webhook From 28f96ed5e581d7ce23db5e7b6df3642277f1de43 Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Sat, 4 Jul 2026 11:06:25 +0200 Subject: [PATCH 03/10] Add oRPC foundation: contexts, middleware, legacy-parity error mapping, handler mounts Public v1 catch-all serves an OpenAPIHandler with the exact legacy success/error envelopes, rate-limit headers, and analytics logging; internal RPC handler replaces the tRPC transport. Organization resource migrated as the exemplar (GET/PATCH/PUT + members) with Speakeasy spec metadata carried on .route(). Legacy organization route files removed (catch-all now serves those paths). --- apps/web/package.json | 6 + .../(internal-api)/rpc/[[...rest]]/route.ts | 42 +++ .../api/(public-api)/v1/[...rest]/route.ts | 93 ++++++ .../v1/organization/members/route.ts | 26 -- .../api/(public-api)/v1/organization/route.ts | 51 --- apps/web/src/lib/api/errors.ts | 2 +- apps/web/src/lib/orpc.ts | 34 ++ apps/web/src/server/orpc/base.ts | 300 ++++++++++++++++++ apps/web/src/server/orpc/internal/api-keys.ts | 2 + apps/web/src/server/orpc/internal/billing.ts | 2 + .../web/src/server/orpc/internal/documents.ts | 2 + apps/web/src/server/orpc/internal/domains.ts | 2 + apps/web/src/server/orpc/internal/helpers.ts | 26 ++ apps/web/src/server/orpc/internal/hosting.ts | 2 + .../src/server/orpc/internal/ingest-jobs.ts | 2 + .../src/server/orpc/internal/namespaces.ts | 2 + .../src/server/orpc/internal/organizations.ts | 2 + apps/web/src/server/orpc/internal/router.ts | 31 ++ apps/web/src/server/orpc/internal/search.ts | 2 + apps/web/src/server/orpc/internal/uploads.ts | 2 + apps/web/src/server/orpc/internal/webhooks.ts | 2 + apps/web/src/server/orpc/public/api-keys.ts | 2 + apps/web/src/server/orpc/public/chat.ts | 2 + .../src/server/orpc/public/code-samples.ts | 28 ++ apps/web/src/server/orpc/public/documents.ts | 2 + apps/web/src/server/orpc/public/hosting.ts | 2 + .../web/src/server/orpc/public/ingest-jobs.ts | 2 + apps/web/src/server/orpc/public/namespaces.ts | 2 + .../src/server/orpc/public/organization.ts | 145 +++++++++ apps/web/src/server/orpc/public/router.ts | 32 ++ apps/web/src/server/orpc/public/search.ts | 2 + apps/web/src/server/orpc/public/uploads.ts | 2 + apps/web/src/server/orpc/public/warm-up.ts | 2 + apps/web/src/server/orpc/public/webhooks.ts | 2 + 34 files changed, 780 insertions(+), 78 deletions(-) create mode 100644 apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts create mode 100644 apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/organization/members/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/organization/route.ts create mode 100644 apps/web/src/lib/orpc.ts create mode 100644 apps/web/src/server/orpc/base.ts create mode 100644 apps/web/src/server/orpc/internal/api-keys.ts create mode 100644 apps/web/src/server/orpc/internal/billing.ts create mode 100644 apps/web/src/server/orpc/internal/documents.ts create mode 100644 apps/web/src/server/orpc/internal/domains.ts create mode 100644 apps/web/src/server/orpc/internal/helpers.ts create mode 100644 apps/web/src/server/orpc/internal/hosting.ts create mode 100644 apps/web/src/server/orpc/internal/ingest-jobs.ts create mode 100644 apps/web/src/server/orpc/internal/namespaces.ts create mode 100644 apps/web/src/server/orpc/internal/organizations.ts create mode 100644 apps/web/src/server/orpc/internal/router.ts create mode 100644 apps/web/src/server/orpc/internal/search.ts create mode 100644 apps/web/src/server/orpc/internal/uploads.ts create mode 100644 apps/web/src/server/orpc/internal/webhooks.ts create mode 100644 apps/web/src/server/orpc/public/api-keys.ts create mode 100644 apps/web/src/server/orpc/public/chat.ts create mode 100644 apps/web/src/server/orpc/public/code-samples.ts create mode 100644 apps/web/src/server/orpc/public/documents.ts create mode 100644 apps/web/src/server/orpc/public/hosting.ts create mode 100644 apps/web/src/server/orpc/public/ingest-jobs.ts create mode 100644 apps/web/src/server/orpc/public/namespaces.ts create mode 100644 apps/web/src/server/orpc/public/organization.ts create mode 100644 apps/web/src/server/orpc/public/router.ts create mode 100644 apps/web/src/server/orpc/public/search.ts create mode 100644 apps/web/src/server/orpc/public/uploads.ts create mode 100644 apps/web/src/server/orpc/public/warm-up.ts create mode 100644 apps/web/src/server/orpc/public/webhooks.ts diff --git a/apps/web/package.json b/apps/web/package.json index 68c79abb..7c472ac1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,12 @@ "@hookform/resolvers": "^5.2.2", "@modelcontextprotocol/sdk": "1.26.0", "@number-flow/react": "^0.5.10", + "@orpc/client": "1.14.6", + "@orpc/json-schema": "1.14.6", + "@orpc/openapi": "1.14.6", + "@orpc/server": "1.14.6", + "@orpc/tanstack-query": "1.14.6", + "@orpc/zod": "1.14.6", "@t3-oss/env-nextjs": "catalog:", "@tanstack/react-query": "catalog:", "@trpc/client": "catalog:", diff --git a/apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts b/apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts new file mode 100644 index 00000000..2f50b219 --- /dev/null +++ b/apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts @@ -0,0 +1,42 @@ +import type { InternalContext } from "@/server/orpc/base"; +import { auth } from "@/lib/auth"; +import { internalRouter } from "@/server/orpc/internal/router"; +import { onError } from "@orpc/server"; +import { RPCHandler } from "@orpc/server/fetch"; +import { BatchHandlerPlugin } from "@orpc/server/plugins"; + +const handler = new RPCHandler(internalRouter, { + plugins: [new BatchHandlerPlugin()], + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); + +async function handleRequest(request: Request) { + // resolve the session once per HTTP request (parity with createTRPCContext), + // shared across batched procedure calls + const session = await auth.api.getSession({ headers: request.headers }); + + const context: InternalContext = { + headers: request.headers, + session, + }; + + const { response } = await handler.handle(request, { + prefix: "/api/rpc", + context, + }); + + return response ?? new Response("Not found", { status: 404 }); +} + +export { + handleRequest as GET, + handleRequest as POST, + handleRequest as PUT, + handleRequest as PATCH, + handleRequest as DELETE, + handleRequest as HEAD, +}; diff --git a/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts b/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts new file mode 100644 index 00000000..8b8d11c5 --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts @@ -0,0 +1,93 @@ +import type { PublicApiContext } from "@/server/orpc/base"; +import type { NextRequest } from "next/server"; +import { + flushServerEvents, + identifyOrganization, + logServerEvent, +} from "@/lib/analytics-server"; +import { + legacyErrorORPCError, + legacyErrorResponseBodyEncoder, +} from "@/server/orpc/base"; +import { publicRouter } from "@/server/orpc/public/router"; +import { SmartCoercionPlugin } from "@orpc/json-schema"; +import { OpenAPIHandler } from "@orpc/openapi/fetch"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; + +export const preferredRegion = "iad1"; // make this closer to the DB +export const maxDuration = 120; + +const handler = new OpenAPIHandler(publicRouter, { + plugins: [ + new SmartCoercionPlugin({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }), + ], + clientInterceptors: [ + async (options) => { + try { + return await options.next(); + } catch (error) { + console.error(error); + throw legacyErrorORPCError(error); + } + }, + ], + customErrorResponseBodyEncoder: legacyErrorResponseBodyEncoder, +}); + +const notFoundResponse = () => + Response.json( + { + success: false, + error: { + code: "not_found", + message: "The requested endpoint does not exist.", + doc_url: "https://docs.agentset.ai/api-reference", + }, + }, + { status: 404 }, + ); + +async function handleRequest(request: NextRequest) { + const context: PublicApiContext = { + headers: request.headers, + resHeaders: {}, + analytics: {}, + }; + + const { response } = await handler.handle(request, { + prefix: "/api/v1", + context, + }); + + if (!response) return notFoundResponse(); + + for (const [key, value] of Object.entries(context.resHeaders)) { + response.headers.set(key, value); + } + + // parity with the legacy wrappers: successful requests are logged, errors + // (thrown before the old logging call) are not + const { organization, tenantId, namespaceId, routeName } = context.analytics; + if (organization && routeName && response.status < 400) { + identifyOrganization(organization); + logServerEvent( + "api_request", + { request, routeName, response, organization }, + { tenantId, ...(namespaceId ? { namespaceId } : {}) }, + ); + flushServerEvents(); + } + + return response; +} + +export { + handleRequest as GET, + handleRequest as POST, + handleRequest as PUT, + handleRequest as PATCH, + handleRequest as DELETE, + handleRequest as HEAD, +}; diff --git a/apps/web/src/app/api/(public-api)/v1/organization/members/route.ts b/apps/web/src/app/api/(public-api)/v1/organization/members/route.ts deleted file mode 100644 index 58d14d60..00000000 --- a/apps/web/src/app/api/(public-api)/v1/organization/members/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { OrganizationMembersSchema } from "@/schemas/api/organization"; -import { getOrganizationMembers } from "@/services/organizations/members"; - -export const GET = withApiHandler( - async ({ organization, headers }) => { - const members = await getOrganizationMembers({ - organizationId: organization.id, - }); - - if (!members) { - throw new AgentsetApiError({ - code: "not_found", - message: "Organization not found.", - }); - } - - return makeApiSuccessResponse({ - data: OrganizationMembersSchema.parse(members), - headers, - }); - }, - { logging: { routeName: "GET /v1/organization/members" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/organization/route.ts b/apps/web/src/app/api/(public-api)/v1/organization/route.ts deleted file mode 100644 index 37637603..00000000 --- a/apps/web/src/app/api/(public-api)/v1/organization/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { updateOrganizationSchema } from "@/schemas/api/organization"; -import { - getOrganization, - toOrganizationResponse, -} from "@/services/organizations/get"; -import { updateOrganization } from "@/services/organizations/update"; - -export const GET = withApiHandler( - async ({ organization, headers }) => { - const org = await getOrganization({ organizationId: organization.id }); - - if (!org) { - throw new AgentsetApiError({ - code: "not_found", - message: "Organization not found.", - }); - } - - return makeApiSuccessResponse({ - data: toOrganizationResponse(org), - headers, - }); - }, - { logging: { routeName: "GET /v1/organization" } }, -); - -export const PATCH = withApiHandler( - async ({ organization, headers, req }) => { - const { name, slug } = await updateOrganizationSchema.parseAsync( - await parseRequestBody(req), - ); - - const updatedOrganization = await updateOrganization({ - organizationId: organization.id, - name, - slug, - }); - - return makeApiSuccessResponse({ - data: toOrganizationResponse(updatedOrganization), - headers, - }); - }, - { logging: { routeName: "PATCH /v1/organization" } }, -); - -export const PUT = PATCH; diff --git a/apps/web/src/lib/api/errors.ts b/apps/web/src/lib/api/errors.ts index 45dfe764..af63f53e 100644 --- a/apps/web/src/lib/api/errors.ts +++ b/apps/web/src/lib/api/errors.ts @@ -21,7 +21,7 @@ export const ErrorCode = z.enum([ const docsBase = "https://docs.agentset.ai"; -const errorCodeToHttpStatus: Record, number> = { +export const errorCodeToHttpStatus: Record, number> = { bad_request: 400, unauthorized: 401, forbidden: 403, diff --git a/apps/web/src/lib/orpc.ts b/apps/web/src/lib/orpc.ts new file mode 100644 index 00000000..b8462122 --- /dev/null +++ b/apps/web/src/lib/orpc.ts @@ -0,0 +1,34 @@ +import type { InternalRouter } from "@/server/orpc/internal/router"; +import type { + InferRouterInputs, + InferRouterOutputs, + RouterClient, +} from "@orpc/server"; +import { getBaseUrl } from "@/lib/utils"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import { BatchLinkPlugin } from "@orpc/client/plugins"; +import { createTanstackQueryUtils } from "@orpc/tanstack-query"; + +const link = new RPCLink({ + url: () => getBaseUrl() + "/api/rpc", + plugins: [ + new BatchLinkPlugin({ + groups: [{ condition: () => true, context: {} }], + }), + ], +}); + +/** Imperative client — the `trpcClient.x.y.query()` replacement. */ +export const client: RouterClient = createORPCClient(link); + +/** TanStack Query utils — the `useTRPC()` replacement (plain import, no hook). */ +export const orpc = createTanstackQueryUtils(client); + +/** + * Inference helpers for inputs/outputs. + * + * @example type Namespaces = RouterOutputs["namespace"]["getOrgNamespaces"] + */ +export type RouterInputs = InferRouterInputs; +export type RouterOutputs = InferRouterOutputs; diff --git a/apps/web/src/server/orpc/base.ts b/apps/web/src/server/orpc/base.ts new file mode 100644 index 00000000..81600e87 --- /dev/null +++ b/apps/web/src/server/orpc/base.ts @@ -0,0 +1,300 @@ +import type { ApiKeyInfo } from "@/lib/api/api-key"; +import type { Session } from "@/lib/auth-types"; +import { getApiKeyInfo } from "@/lib/api/api-key"; +import { + AgentsetApiError, + errorCodeToHttpStatus, + handleApiError, +} from "@/lib/api/errors"; +import { getNamespace } from "@/lib/api/handler/namespace"; +import { ratelimit } from "@/lib/api/rate-limit"; +import { tenantHeaderSchema } from "@/openapi/v1/utils"; +import { ORPCError, os, ValidationError } from "@orpc/server"; +import { z, ZodError } from "zod/v4"; + +import type { Namespace, Organization } from "@agentset/db"; +import { normalizeId, tryCatch } from "@agentset/utils"; + +/** + * --------------------------------------------------------------------------- + * Public API surface (api.agentset.ai/v1) — org API-key auth + * --------------------------------------------------------------------------- + */ + +export type PublicOrganization = Pick & + ApiKeyInfo["organization"]; + +/** + * Mutable bags on the initial context: middlewares write into them in place so + * the route handler can read rate-limit headers / analytics identity after + * `handler.handle()` returns (success or error). + */ +export interface PublicApiContext { + headers: Headers; + resHeaders: Record; + analytics: { + organization?: PublicOrganization; + tenantId?: string; + namespaceId?: string; + routeName?: string; + }; +} + +const publicBase = os.$context(); + +const getTenantFromHeaders = (headers: Headers) => { + const tenantId = headers.get("x-tenant-id") ?? undefined; + + const parsedTenantId = tenantHeaderSchema.safeParse(tenantId); + if (!parsedTenantId.success) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid tenant ID.", + }); + } + + return parsedTenantId.data; +}; + +/** + * Port of `withApiHandler` (lib/api/handler/base.ts): loose Bearer parsing, + * cached key lookup, per-org rate limit (headers attached to every response), + * x-tenant-id validation. Error messages must stay byte-identical. + */ +const apiKeyAuthMiddleware = publicBase.middleware( + async ({ context, next, procedure }) => { + let apiKey: string | undefined = undefined; + + const authorizationHeader = context.headers.get("Authorization"); + if (authorizationHeader) { + if (!authorizationHeader.includes("Bearer ")) { + throw new AgentsetApiError({ + code: "bad_request", + message: + "Misconfigured authorization header. Did you forget to add 'Bearer '?", + }); + } + apiKey = authorizationHeader.replace("Bearer ", ""); + } + + if (!apiKey) { + throw new AgentsetApiError({ + code: "unauthorized", + message: "Unauthorized: Invalid API key.", + }); + } + + const orgApiKey = await tryCatch(getApiKeyInfo(apiKey)); + if (!orgApiKey.data) { + throw new AgentsetApiError({ + code: "unauthorized", + message: "Unauthorized: Invalid API key.", + }); + } + + const rateLimit = orgApiKey.data.organization.apiRatelimit; + const { success, limit, reset, remaining } = await ratelimit( + rateLimit, + "1 m", + ).limit(orgApiKey.data.organizationId); + + Object.assign(context.resHeaders, { + "Retry-After": reset.toString(), + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }); + + if (!success) { + throw new AgentsetApiError({ + code: "rate_limit_exceeded", + message: "Too many requests.", + }); + } + + const tenantId = getTenantFromHeaders(context.headers); + const organization: PublicOrganization = { + id: orgApiKey.data.organizationId, + ...orgApiKey.data.organization, + }; + + const { method, path } = procedure["~orpc"].route; + context.analytics.organization = organization; + context.analytics.tenantId = tenantId; + // keep the legacy `GET /v1/organization` / `POST /v1/namespace/[namespaceId]/chat` labels + context.analytics.routeName = path + ? `${method ?? "POST"} /v1${path.replace(/\{(\w+)\}/g, "[$1]")}` + : undefined; + + return next({ + context: { + organization, + apiScope: orgApiKey.data.scope, + tenantId, + }, + }); + }, +); + +/** Base builder for public v1 procedures (org resolved, rate limited). */ +export const publicApi = publicBase.use(apiKeyAuthMiddleware); + +/** + * Port of `withNamespaceApiHandler`: apply per procedure AFTER `.input()` as + * `.use(requireNamespace, (input) => input.namespaceId)`. + */ +export const requireNamespace = os + .$context() + .middleware(async ({ context, next }, namespaceIdInput: string) => { + const namespaceId = normalizeId(namespaceIdInput ?? "", "ns_"); + if (!namespaceId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid namespace ID.", + }); + } + + const namespace = await getNamespace({ + namespaceId, + organizationId: context.organization.id, + }); + + if (!namespace) { + throw new AgentsetApiError({ + code: "unauthorized", + message: "Unauthorized: You don't have access to this namespace.", + }); + } + + context.analytics.namespaceId = namespace.id; + + return next({ context: { namespace: namespace as Namespace } }); + }); + +/** Success envelope — the response schema of every enveloped v1 endpoint. */ +export const successSchema = ( + data: T, + { hasPagination }: { hasPagination?: boolean } = {}, +) => + z.object({ + success: z.literal(true), + data, + ...(hasPagination && { + pagination: z.object({ + nextCursor: z.string().nullable(), + prevCursor: z.string().nullable(), + hasMore: z.boolean(), + }), + }), + }); + +/** + * Legacy error mapping. Anything thrown inside a public procedure funnels + * through here (as a client interceptor on the OpenAPIHandler) and is + * re-thrown as an ORPCError whose status matches `errorCodeToHttpStatus` and + * whose `data` carries the exact legacy body for the response encoder. + */ +export interface LegacyErrorBody { + success: false; + error: { code: string; message: string; doc_url?: string }; +} + +export const toLegacyErrorParts = ( + error: unknown, +): { status: number; body: LegacyErrorBody } => { + let cause = error; + + // oRPC wraps input-validation failures; unwrap back into a ZodError so + // handleApiError formats them as 422 with the zod-error message (parity + // with the old `schema.parseAsync(body)` behavior). + if ( + error instanceof ORPCError && + error.code === "BAD_REQUEST" && + error.cause instanceof ValidationError + ) { + cause = new ZodError(error.cause.issues as never); + } + + const { error: legacyError, status } = handleApiError(cause); + return { status, body: { success: false, error: legacyError } }; +}; + +export const legacyErrorORPCError = (error: unknown) => { + if ( + error instanceof ORPCError && + (error.data as LegacyErrorBody | undefined)?.success === false + ) { + return error; // already converted + } + + const { status, body } = toLegacyErrorParts(error); + return new ORPCError(body.error.code.toUpperCase(), { + status, + message: body.error.message, + data: body, + }); +}; + +/** OpenAPIHandler option: emit the legacy error envelope as the body. */ +export const legacyErrorResponseBodyEncoder = ( + error: ORPCError, +): LegacyErrorBody => { + const data = error.data as LegacyErrorBody | undefined; + if (data?.success === false) return data; + return toLegacyErrorParts(error).body; +}; + +/** + * --------------------------------------------------------------------------- + * Internal surface (dashboard RPC) — better-auth session + * --------------------------------------------------------------------------- + */ + +export interface InternalContext { + headers: Headers; + /** resolved once per HTTP request in the rpc route handler */ + session: Session | null; +} + +const internalBase = os.$context(); + +/** + * Port of the tRPC `timingMiddleware`: timing log + AgentsetApiError → typed + * transport error (dashboard mutations surface 4xx instead of 500). + */ +const timingMiddleware = internalBase.middleware(async ({ next, path }) => { + const start = Date.now(); + + try { + const result = await next(); + + const end = Date.now(); + console.log(`[RPC] ${path.join(".")} took ${end - start}ms to execute`); + + return result; + } catch (error) { + if (error instanceof AgentsetApiError) { + throw new ORPCError(error.code.toUpperCase(), { + status: errorCodeToHttpStatus[error.code], + message: error.message, + }); + } + throw error; + } +}); + +export const publicProcedure = internalBase.use(timingMiddleware); + +export const protectedProcedure = publicProcedure.use(({ context, next }) => { + if (!context.session) { + throw new ORPCError("UNAUTHORIZED"); + } + + return next({ + context: { + session: context.session, + }, + }); +}); + +export type ProtectedContext = InternalContext & { session: Session }; diff --git a/apps/web/src/server/orpc/internal/api-keys.ts b/apps/web/src/server/orpc/internal/api-keys.ts new file mode 100644 index 00000000..d354bd63 --- /dev/null +++ b/apps/web/src/server/orpc/internal/api-keys.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const apiKeysRouter = {}; diff --git a/apps/web/src/server/orpc/internal/billing.ts b/apps/web/src/server/orpc/internal/billing.ts new file mode 100644 index 00000000..6671897a --- /dev/null +++ b/apps/web/src/server/orpc/internal/billing.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const billingRouter = {}; diff --git a/apps/web/src/server/orpc/internal/documents.ts b/apps/web/src/server/orpc/internal/documents.ts new file mode 100644 index 00000000..2433210a --- /dev/null +++ b/apps/web/src/server/orpc/internal/documents.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const documentsRouter = {}; diff --git a/apps/web/src/server/orpc/internal/domains.ts b/apps/web/src/server/orpc/internal/domains.ts new file mode 100644 index 00000000..a7cfc626 --- /dev/null +++ b/apps/web/src/server/orpc/internal/domains.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const domainsRouter = {}; diff --git a/apps/web/src/server/orpc/internal/helpers.ts b/apps/web/src/server/orpc/internal/helpers.ts new file mode 100644 index 00000000..b1f3faa3 --- /dev/null +++ b/apps/web/src/server/orpc/internal/helpers.ts @@ -0,0 +1,26 @@ +import type { ProtectedContext } from "@/server/orpc/base"; +import { cache } from "react"; + +import { db } from "@agentset/db/client"; + +export const getNamespaceByUser = cache( + async ( + ctx: Pick, + idOrSlug: + | { + id: string; + } + | { + slug: string; + }, + ) => { + return await db.namespace.findFirst({ + where: { + ...("id" in idOrSlug ? { id: idOrSlug.id } : { slug: idOrSlug.slug }), + organization: { + members: { some: { userId: ctx.session.user.id } }, + }, + }, + }); + }, +); diff --git a/apps/web/src/server/orpc/internal/hosting.ts b/apps/web/src/server/orpc/internal/hosting.ts new file mode 100644 index 00000000..8c4e580b --- /dev/null +++ b/apps/web/src/server/orpc/internal/hosting.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const hostingRouter = {}; diff --git a/apps/web/src/server/orpc/internal/ingest-jobs.ts b/apps/web/src/server/orpc/internal/ingest-jobs.ts new file mode 100644 index 00000000..a308ca73 --- /dev/null +++ b/apps/web/src/server/orpc/internal/ingest-jobs.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const ingestJobsRouter = {}; diff --git a/apps/web/src/server/orpc/internal/namespaces.ts b/apps/web/src/server/orpc/internal/namespaces.ts new file mode 100644 index 00000000..57399170 --- /dev/null +++ b/apps/web/src/server/orpc/internal/namespaces.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const namespacesRouter = {}; diff --git a/apps/web/src/server/orpc/internal/organizations.ts b/apps/web/src/server/orpc/internal/organizations.ts new file mode 100644 index 00000000..6ef7df6c --- /dev/null +++ b/apps/web/src/server/orpc/internal/organizations.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const organizationsRouter = {}; diff --git a/apps/web/src/server/orpc/internal/router.ts b/apps/web/src/server/orpc/internal/router.ts new file mode 100644 index 00000000..e0ca74d6 --- /dev/null +++ b/apps/web/src/server/orpc/internal/router.ts @@ -0,0 +1,31 @@ +import { apiKeysRouter } from "./api-keys"; +import { billingRouter } from "./billing"; +import { documentsRouter } from "./documents"; +import { domainsRouter } from "./domains"; +import { hostingRouter } from "./hosting"; +import { ingestJobsRouter } from "./ingest-jobs"; +import { namespacesRouter } from "./namespaces"; +import { organizationsRouter } from "./organizations"; +import { searchRouter } from "./search"; +import { uploadsRouter } from "./uploads"; +import { webhooksRouter } from "./webhooks"; + +/** + * The dashboard RPC surface. Mount names intentionally match the old tRPC + * root router 1:1 so client-side query keys keep the same path segments. + */ +export const internalRouter = { + namespace: namespacesRouter, + apiKey: apiKeysRouter, + ingestJob: ingestJobsRouter, + document: documentsRouter, + upload: uploadsRouter, + billing: billingRouter, + organization: organizationsRouter, + hosting: hostingRouter, + domain: domainsRouter, + search: searchRouter, + webhook: webhooksRouter, +}; + +export type InternalRouter = typeof internalRouter; diff --git a/apps/web/src/server/orpc/internal/search.ts b/apps/web/src/server/orpc/internal/search.ts new file mode 100644 index 00000000..fabadaef --- /dev/null +++ b/apps/web/src/server/orpc/internal/search.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const searchRouter = {}; diff --git a/apps/web/src/server/orpc/internal/uploads.ts b/apps/web/src/server/orpc/internal/uploads.ts new file mode 100644 index 00000000..810352d9 --- /dev/null +++ b/apps/web/src/server/orpc/internal/uploads.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const uploadsRouter = {}; diff --git a/apps/web/src/server/orpc/internal/webhooks.ts b/apps/web/src/server/orpc/internal/webhooks.ts new file mode 100644 index 00000000..7062b1a5 --- /dev/null +++ b/apps/web/src/server/orpc/internal/webhooks.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const webhooksRouter = {}; diff --git a/apps/web/src/server/orpc/public/api-keys.ts b/apps/web/src/server/orpc/public/api-keys.ts new file mode 100644 index 00000000..d354bd63 --- /dev/null +++ b/apps/web/src/server/orpc/public/api-keys.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const apiKeysRouter = {}; diff --git a/apps/web/src/server/orpc/public/chat.ts b/apps/web/src/server/orpc/public/chat.ts new file mode 100644 index 00000000..f6b1a261 --- /dev/null +++ b/apps/web/src/server/orpc/public/chat.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const chatRouter = {}; diff --git a/apps/web/src/server/orpc/public/code-samples.ts b/apps/web/src/server/orpc/public/code-samples.ts new file mode 100644 index 00000000..e032a4a6 --- /dev/null +++ b/apps/web/src/server/orpc/public/code-samples.ts @@ -0,0 +1,28 @@ +export const ts = (statements: TemplateStringsArray) => { + return statements.join("\n").trim(); +}; + +// We're not using Speakeasy for the TS SDK and instead maintain one ourselves. +// This helper function adds TS code samples to the operation object. +// It also converts the TS code to JS +export const makeCodeSamples = ( + code: string, + { isNs = true }: { isNs?: boolean } = {}, +) => { + const fullCode = ` +import { Agentset } from "agentset"; +${code.includes("fs.") ? "import fs from 'fs';\n" : ""} +const agentset = new Agentset({ apiKey: 'agentset_xxx' }); +${isNs ? "const ns = agentset.namespace('ns_xxx');\n" : ""} +${code} +`; + + return { + "x-codeSamples": [ + { + lang: "TypeScript", + source: fullCode, + }, + ], + }; +}; diff --git a/apps/web/src/server/orpc/public/documents.ts b/apps/web/src/server/orpc/public/documents.ts new file mode 100644 index 00000000..2433210a --- /dev/null +++ b/apps/web/src/server/orpc/public/documents.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const documentsRouter = {}; diff --git a/apps/web/src/server/orpc/public/hosting.ts b/apps/web/src/server/orpc/public/hosting.ts new file mode 100644 index 00000000..8c4e580b --- /dev/null +++ b/apps/web/src/server/orpc/public/hosting.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const hostingRouter = {}; diff --git a/apps/web/src/server/orpc/public/ingest-jobs.ts b/apps/web/src/server/orpc/public/ingest-jobs.ts new file mode 100644 index 00000000..a308ca73 --- /dev/null +++ b/apps/web/src/server/orpc/public/ingest-jobs.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const ingestJobsRouter = {}; diff --git a/apps/web/src/server/orpc/public/namespaces.ts b/apps/web/src/server/orpc/public/namespaces.ts new file mode 100644 index 00000000..57399170 --- /dev/null +++ b/apps/web/src/server/orpc/public/namespaces.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const namespacesRouter = {}; diff --git a/apps/web/src/server/orpc/public/organization.ts b/apps/web/src/server/orpc/public/organization.ts new file mode 100644 index 00000000..b29e044d --- /dev/null +++ b/apps/web/src/server/orpc/public/organization.ts @@ -0,0 +1,145 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { publicApi, successSchema } from "@/server/orpc/base"; +import { + OrganizationMembersSchema, + OrganizationSchema, + updateOrganizationSchema, +} from "@/schemas/api/organization"; +import { + getOrganization, + toOrganizationResponse, +} from "@/services/organizations/get"; +import { getOrganizationMembers } from "@/services/organizations/members"; +import { updateOrganization } from "@/services/organizations/update"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const get = publicApi + .route({ + method: "GET", + path: "/organization", + operationId: "getOrganization", + summary: "Retrieve the organization", + description: + "Retrieve the organization associated with the API key, including its usage and limits.", + tags: ["Organization"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "get", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const organization = await agentset.organization.get(); +console.log(organization); +`, + { isNs: false }, + ), + }), + }) + .output(successSchema(OrganizationSchema)) + .handler(async ({ context }) => { + const org = await getOrganization({ + organizationId: context.organization.id, + }); + + if (!org) { + throw new AgentsetApiError({ + code: "not_found", + message: "Organization not found.", + }); + } + + return { success: true as const, data: toOrganizationResponse(org) }; + }); + +const updateHandler = publicApi + .input(updateOrganizationSchema) + .output(successSchema(OrganizationSchema)) + .handler(async ({ context, input }) => { + const updatedOrganization = await updateOrganization({ + organizationId: context.organization.id, + name: input.name, + slug: input.slug, + }); + + return { + success: true as const, + data: toOrganizationResponse(updatedOrganization), + }; + }); + +const update = updateHandler.route({ + method: "PATCH", + path: "/organization", + operationId: "updateOrganization", + summary: "Update the organization", + description: + "Update the name and/or slug of the organization associated with the API key.", + tags: ["Organization"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "update", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const organization = await agentset.organization.update({ + name: "My Organization", +}); +console.log(organization); +`, + { isNs: false }, + ), + }), +}); + +// legacy wire-compat alias, hidden from the generated spec +const updatePut = updateHandler.route({ + method: "PUT", + path: "/organization", + tags: ["internal-alias"], +}); + +const members = publicApi + .route({ + method: "GET", + path: "/organization/members", + operationId: "listOrganizationMembers", + summary: "Retrieve the organization members", + description: + "Retrieve the members and pending invitations of the organization associated with the API key.", + tags: ["Organization"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "members", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const members = await agentset.organization.members(); +console.log(members); +`, + { isNs: false }, + ), + }), + }) + .output(successSchema(OrganizationMembersSchema)) + .handler(async ({ context }) => { + const orgMembers = await getOrganizationMembers({ + organizationId: context.organization.id, + }); + + if (!orgMembers) { + throw new AgentsetApiError({ + code: "not_found", + message: "Organization not found.", + }); + } + + return { success: true as const, data: orgMembers }; + }); + +export const organizationRouter = { + get, + update, + updatePut, + members, +}; diff --git a/apps/web/src/server/orpc/public/router.ts b/apps/web/src/server/orpc/public/router.ts new file mode 100644 index 00000000..6b520994 --- /dev/null +++ b/apps/web/src/server/orpc/public/router.ts @@ -0,0 +1,32 @@ +import { apiKeysRouter } from "./api-keys"; +import { chatRouter } from "./chat"; +import { documentsRouter } from "./documents"; +import { hostingRouter } from "./hosting"; +import { ingestJobsRouter } from "./ingest-jobs"; +import { namespacesRouter } from "./namespaces"; +import { organizationRouter } from "./organization"; +import { searchRouter } from "./search"; +import { uploadsRouter } from "./uploads"; +import { warmUpRouter } from "./warm-up"; +import { webhooksRouter } from "./webhooks"; + +/** + * The public v1 REST surface (api.agentset.ai/v1). Routing is driven entirely + * by each procedure's `.route({ method, path })` — the nesting here is + * organizational only. + */ +export const publicRouter = { + organization: organizationRouter, + apiKeys: apiKeysRouter, + namespaces: namespacesRouter, + documents: documentsRouter, + ingestJobs: ingestJobsRouter, + uploads: uploadsRouter, + search: searchRouter, + chat: chatRouter, + warmUp: warmUpRouter, + hosting: hostingRouter, + webhooks: webhooksRouter, +}; + +export type PublicRouter = typeof publicRouter; diff --git a/apps/web/src/server/orpc/public/search.ts b/apps/web/src/server/orpc/public/search.ts new file mode 100644 index 00000000..fabadaef --- /dev/null +++ b/apps/web/src/server/orpc/public/search.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const searchRouter = {}; diff --git a/apps/web/src/server/orpc/public/uploads.ts b/apps/web/src/server/orpc/public/uploads.ts new file mode 100644 index 00000000..810352d9 --- /dev/null +++ b/apps/web/src/server/orpc/public/uploads.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const uploadsRouter = {}; diff --git a/apps/web/src/server/orpc/public/warm-up.ts b/apps/web/src/server/orpc/public/warm-up.ts new file mode 100644 index 00000000..00f804be --- /dev/null +++ b/apps/web/src/server/orpc/public/warm-up.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const warmUpRouter = {}; diff --git a/apps/web/src/server/orpc/public/webhooks.ts b/apps/web/src/server/orpc/public/webhooks.ts new file mode 100644 index 00000000..7062b1a5 --- /dev/null +++ b/apps/web/src/server/orpc/public/webhooks.ts @@ -0,0 +1,2 @@ +// Stub — replaced by the oRPC migration fan-out. +export const webhooksRouter = {}; From dd1658aecc9b8279a17f3c830f44926bebcbc40e Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Sat, 4 Jul 2026 11:25:32 +0200 Subject: [PATCH 04/10] Migrate the public v1 surface and all dashboard routers to oRPC All 21 remaining v1 endpoints are now oRPC procedures behind the catch-all handler (wire-parity: envelopes, status codes, 204s, PUT aliases, pagination, tenant scoping, chat SSE via detailed-output stream passthrough), with the Speakeasy spec metadata carried on .route(). Legacy v1 route files removed. All 52 tRPC procedures ported 1:1 to oRPC internal routers on the same service layer; tRPC files remain until the client migration lands. --- .../(public-api)/v1/api-keys/[keyId]/route.ts | 21 - .../app/api/(public-api)/v1/api-keys/route.ts | 54 --- .../v1/namespace/[namespaceId]/chat/route.ts | 56 --- .../[documentId]/chunks-download-url/route.ts | 24 -- .../[documentId]/file-download-url/route.ts | 24 -- .../documents/[documentId]/route.ts | 57 --- .../[namespaceId]/documents/route.ts | 55 --- .../[namespaceId]/hosting/domain/route.ts | 92 ---- .../namespace/[namespaceId]/hosting/route.ts | 82 ---- .../ingest-jobs/[jobId]/re-ingest/route.ts | 37 -- .../ingest-jobs/[jobId]/route.ts | 80 ---- .../[namespaceId]/ingest-jobs/route.ts | 94 ----- .../v1/namespace/[namespaceId]/route.ts | 60 --- .../namespace/[namespaceId]/search/route.ts | 36 -- .../[namespaceId]/uploads/batch/route.ts | 34 -- .../namespace/[namespaceId]/uploads/route.ts | 32 -- .../namespace/[namespaceId]/warm-up/route.ts | 25 -- .../api/(public-api)/v1/namespace/route.ts | 63 --- .../[webhookId]/regenerate-secret/route.ts | 38 -- .../v1/webhooks/[webhookId]/route.ts | 84 ---- .../app/api/(public-api)/v1/webhooks/route.ts | 60 --- apps/web/src/server/orpc/internal/api-keys.ts | 114 ++++- apps/web/src/server/orpc/internal/billing.ts | 396 +++++++++++++++++- .../web/src/server/orpc/internal/documents.ts | 194 ++++++++- apps/web/src/server/orpc/internal/domains.ts | 70 +++- apps/web/src/server/orpc/internal/hosting.ts | 75 +++- .../src/server/orpc/internal/ingest-jobs.ts | 156 ++++++- .../src/server/orpc/internal/namespaces.ts | 279 +++++++++++- .../src/server/orpc/internal/organizations.ts | 132 +++++- apps/web/src/server/orpc/internal/search.ts | 62 ++- apps/web/src/server/orpc/internal/uploads.ts | 72 +++- apps/web/src/server/orpc/internal/webhooks.ts | 353 +++++++++++++++- apps/web/src/server/orpc/public/api-keys.ts | 153 ++++++- apps/web/src/server/orpc/public/chat.ts | 144 ++++++- apps/web/src/server/orpc/public/documents.ts | 323 +++++++++++++- apps/web/src/server/orpc/public/hosting.ts | 330 ++++++++++++++- .../web/src/server/orpc/public/ingest-jobs.ts | 346 ++++++++++++++- apps/web/src/server/orpc/public/namespaces.ts | 239 ++++++++++- apps/web/src/server/orpc/public/search.ts | 90 +++- apps/web/src/server/orpc/public/uploads.ts | 161 ++++++- apps/web/src/server/orpc/public/warm-up.ts | 88 +++- apps/web/src/server/orpc/public/webhooks.ts | 321 +++++++++++++- 42 files changed, 4056 insertions(+), 1150 deletions(-) delete mode 100644 apps/web/src/app/api/(public-api)/v1/api-keys/[keyId]/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/api-keys/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/chat/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/domain/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/batch/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/warm-up/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/namespace/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/regenerate-secret/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/route.ts delete mode 100644 apps/web/src/app/api/(public-api)/v1/webhooks/route.ts diff --git a/apps/web/src/app/api/(public-api)/v1/api-keys/[keyId]/route.ts b/apps/web/src/app/api/(public-api)/v1/api-keys/[keyId]/route.ts deleted file mode 100644 index 53bbd80e..00000000 --- a/apps/web/src/app/api/(public-api)/v1/api-keys/[keyId]/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withApiHandler } from "@/lib/api/handler"; -import { deleteApiKey } from "@/services/api-key/delete"; - -export const DELETE = withApiHandler( - async ({ organization, params, headers }) => { - const keyId = params.keyId; - if (!keyId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid API key ID.", - }); - } - - // scoped to the organization; a missing key throws P2025 which maps to a 404 - await deleteApiKey({ id: keyId, organizationId: organization.id }); - - return new Response(null, { status: 204, headers }); - }, - { logging: { routeName: "DELETE /v1/api-keys/[keyId]" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/api-keys/route.ts b/apps/web/src/app/api/(public-api)/v1/api-keys/route.ts deleted file mode 100644 index e947bb0e..00000000 --- a/apps/web/src/app/api/(public-api)/v1/api-keys/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { withApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { - ApiKeySchema, - createApiKeyBodySchema, - CreatedApiKeySchema, -} from "@/schemas/api/api-key"; -import { createApiKey } from "@/services/api-key/create"; -import { listApiKeys } from "@/services/api-key/list"; - -import { prefixId } from "@agentset/utils"; - -export const GET = withApiHandler( - async ({ organization, headers }) => { - const apiKeys = await listApiKeys({ organizationId: organization.id }); - - return makeApiSuccessResponse({ - data: apiKeys.map((apiKey) => - ApiKeySchema.parse({ - ...apiKey, - organizationId: prefixId(apiKey.organizationId, "org_"), - }), - ), - headers, - }); - }, - { logging: { routeName: "GET /v1/api-keys" } }, -); - -export const POST = withApiHandler( - async ({ organization, headers, req }) => { - const { label, scope } = await createApiKeyBodySchema.parseAsync( - await parseRequestBody(req), - ); - - // TODO: check apiScope - const apiKey = await createApiKey({ - organizationId: organization.id, - label, - scope, - }); - - return makeApiSuccessResponse({ - data: CreatedApiKeySchema.parse({ - ...apiKey, - organizationId: prefixId(apiKey.organizationId, "org_"), - }), - headers, - status: 201, - }); - }, - { logging: { routeName: "POST /v1/api-keys" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/chat/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/chat/route.ts deleted file mode 100644 index 297495c9..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/chat/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { - checkSearchLimit, - incrementOrganizationSearchUsage, -} from "@/lib/api/usage"; -import { parseRequestBody } from "@/lib/api/utils"; -import { generateChat, streamChat } from "@/lib/chat"; -import { chatSchema } from "@/schemas/api/chat"; -import { toModelMessages } from "@/services/chat"; - -export const preferredRegion = "iad1"; // make this closer to the DB -export const maxDuration = 120; - -export const POST = withNamespaceApiHandler( - async ({ req, namespace, tenantId, organization, headers }) => { - // TODO: set hard limits to prevent abuse - checkSearchLimit(organization); - - const body = await chatSchema.parseAsync(await parseRequestBody(req)); - - const messages = toModelMessages(body.messages); - - const onUsageIncrement = (queries: number) => { - incrementOrganizationSearchUsage(organization.id, queries); - }; - - if (body.stream) { - return streamChat({ - namespace, - tenantId, - messages, - options: body, - headers, - onUsageIncrement, - }); - } - - const { text, sources } = await generateChat({ - namespace, - tenantId, - messages, - options: body, - onUsageIncrement, - }); - - return makeApiSuccessResponse({ - data: { - message: { role: "assistant", content: text }, - sources, - }, - headers, - }); - }, - { logging: { routeName: "POST /v1/namespace/[namespaceId]/chat" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts deleted file mode 100644 index cfc802a8..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { getDocumentChunksDownloadUrl } from "@/services/documents/download"; - -export const POST = withNamespaceApiHandler( - async ({ params, namespace, tenantId, headers }) => { - const data = await getDocumentChunksDownloadUrl({ - namespaceId: namespace.id, - documentId: params.documentId ?? "", - tenantId, - }); - - return makeApiSuccessResponse({ - data, - headers, - }); - }, - { - logging: { - routeName: - "POST /v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url", - }, - }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts deleted file mode 100644 index f08a81ec..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { getDocumentFileDownloadUrl } from "@/services/documents/download"; - -export const POST = withNamespaceApiHandler( - async ({ params, namespace, tenantId, headers }) => { - const data = await getDocumentFileDownloadUrl({ - namespaceId: namespace.id, - documentId: params.documentId ?? "", - tenantId, - }); - - return makeApiSuccessResponse({ - data, - headers, - }); - }, - { - logging: { - routeName: - "POST /v1/namespace/[namespaceId]/documents/[documentId]/file-download-url", - }, - }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts deleted file mode 100644 index eb7d4c14..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { DocumentSchema } from "@/schemas/api/document"; -import { queueDocumentDeletion } from "@/services/documents/delete"; -import { getDocumentOrThrow } from "@/services/documents/get"; - -import { prefixId } from "@agentset/utils"; - -export const GET = withNamespaceApiHandler( - async ({ params, namespace, tenantId, headers }) => { - const doc = await getDocumentOrThrow({ - namespaceId: namespace.id, - documentId: params.documentId ?? "", - tenantId, - }); - - return makeApiSuccessResponse({ - data: DocumentSchema.parse({ - ...doc, - id: prefixId(doc.id, "doc_"), - ingestJobId: prefixId(doc.ingestJobId, "job_"), - }), - headers, - }); - }, - { - logging: { - routeName: "GET /v1/namespace/[namespaceId]/documents/[documentId]", - }, - }, -); - -export const DELETE = withNamespaceApiHandler( - async ({ params, namespace, tenantId, headers }) => { - // TODO: check apiScope - const data = await queueDocumentDeletion({ - namespaceId: namespace.id, - organizationId: namespace.organizationId, - documentId: params.documentId ?? "", - tenantId, - }); - - return makeApiSuccessResponse({ - data: DocumentSchema.parse({ - ...data, - id: prefixId(data.id, "doc_"), - ingestJobId: prefixId(data.ingestJobId, "job_"), - }), - headers, - }); - }, - { - logging: { - routeName: "DELETE /v1/namespace/[namespaceId]/documents/[documentId]", - }, - }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/route.ts deleted file mode 100644 index 47044d3d..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { normalizeId, prefixId } from "@agentset/utils"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { DocumentSchema, getDocumentsSchema } from "@/schemas/api/document"; -import { getPaginationArgs, paginateResults } from "@/services/pagination"; - -import { db } from "@agentset/db/client"; - -export const GET = withNamespaceApiHandler( - async ({ searchParams, namespace, tenantId, headers }) => { - const query = await getDocumentsSchema.parseAsync(searchParams); - - // For backward pagination we scan in the opposite direction, then reverse results. - const { where, ...paginationArgs } = getPaginationArgs( - query, - { - orderBy: query.orderBy, - order: query.order, - }, - "doc_", - ); - - const documents = await db.document.findMany({ - where: { - tenantId, - namespaceId: namespace.id, - ...(query.ingestJobId && { - ingestJobId: normalizeId(query.ingestJobId, "job_"), - }), - ...(query.statuses && - query.statuses.length > 0 && { status: { in: query.statuses } }), - ...where, - }, - ...paginationArgs, - }); - - const paginated = paginateResults( - query, - documents.map((doc) => - DocumentSchema.parse({ - ...doc, - ingestJobId: prefixId(doc.ingestJobId, "job_"), - id: prefixId(doc.id, "doc_"), - }), - ), - ); - - return makeApiSuccessResponse({ - data: paginated.records, - pagination: paginated.pagination, - headers, - }); - }, - { logging: { routeName: "GET /v1/namespace/[namespaceId]/documents" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/domain/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/domain/route.ts deleted file mode 100644 index 94a6148b..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/domain/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler/namespace"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { - addDomainSchema, - DomainSchema, - DomainStatusSchema, -} from "@/schemas/api/hosting"; -import { addDomain } from "@/services/domains/add"; -import { checkDomainStatus } from "@/services/domains/check-status"; -import { removeDomain } from "@/services/domains/remove"; -import { getHosting } from "@/services/hosting/get"; - -const getHostingOrThrow = async (namespaceId: string) => { - const hosting = await getHosting({ namespaceId }); - - if (!hosting) { - throw new AgentsetApiError({ - code: "not_found", - message: "Hosting is not enabled for this namespace.", - }); - } - - return hosting; -}; - -export const GET = withNamespaceApiHandler( - async ({ namespace, headers }) => { - const hosting = await getHostingOrThrow(namespace.id); - - if (!hosting.domain) { - throw new AgentsetApiError({ - code: "not_found", - message: "No custom domain is set for this namespace.", - }); - } - - const { status, response } = await checkDomainStatus({ - hostingId: hosting.id, - }); - - return makeApiSuccessResponse({ - // the Vercel responses omit fields when the domain is not found, - // the schema defaults fill those in - data: DomainStatusSchema.parse({ - domain: hosting.domain.slug, - status, - verified: status === "Valid Configuration", - apexName: response.domainJson.apexName, - verification: response.domainJson.verification, - conflicts: response.configJson.conflicts, - misconfigured: response.configJson.misconfigured, - }), - headers, - }); - }, - { logging: { routeName: "GET /v1/namespace/[namespaceId]/hosting/domain" } }, -); - -export const POST = withNamespaceApiHandler( - async ({ namespace, headers, req }) => { - const body = await addDomainSchema.parseAsync(await parseRequestBody(req)); - const hosting = await getHostingOrThrow(namespace.id); - - const domain = await addDomain({ - hostingId: hosting.id, - domain: body.domain, - }); - - return makeApiSuccessResponse({ - data: DomainSchema.parse(domain), - headers, - status: 201, - }); - }, - { logging: { routeName: "POST /v1/namespace/[namespaceId]/hosting/domain" } }, -); - -export const DELETE = withNamespaceApiHandler( - async ({ namespace, headers }) => { - const hosting = await getHostingOrThrow(namespace.id); - await removeDomain({ hostingId: hosting.id }); - - return new Response(null, { status: 204, headers }); - }, - { - logging: { - routeName: "DELETE /v1/namespace/[namespaceId]/hosting/domain", - }, - }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts deleted file mode 100644 index 96eea7e4..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler/namespace"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { HostingSchema, updateHostingSchema } from "@/schemas/api/hosting"; -import { deleteHosting } from "@/services/hosting/delete"; -import { enableHosting } from "@/services/hosting/enable"; -import { getHosting } from "@/services/hosting/get"; -import { updateHosting } from "@/services/hosting/update"; - -import { prefixId } from "@agentset/utils"; - -export const GET = withNamespaceApiHandler( - async ({ namespace, headers }) => { - const hosting = await getHosting({ namespaceId: namespace.id }); - - if (!hosting) { - throw new AgentsetApiError({ - code: "not_found", - message: "Hosting not found for this namespace.", - }); - } - - return makeApiSuccessResponse({ - data: HostingSchema.parse({ - ...hosting, - namespaceId: prefixId(hosting.namespaceId, "ns_"), - }), - headers, - }); - }, - { logging: { routeName: "GET /v1/namespace/[namespaceId]/hosting" } }, -); - -export const POST = withNamespaceApiHandler( - async ({ namespace, headers }) => { - const hosting = await enableHosting({ namespaceId: namespace.id }); - - return makeApiSuccessResponse({ - data: HostingSchema.parse({ - ...hosting, - namespaceId: prefixId(hosting.namespaceId, "ns_"), - }), - headers, - status: 201, - }); - }, - { logging: { routeName: "POST /v1/namespace/[namespaceId]/hosting" } }, -); - -export const PATCH = withNamespaceApiHandler( - async ({ namespace, headers, req }) => { - const body = await updateHostingSchema.parseAsync( - await parseRequestBody(req), - ); - - const updatedHosting = await updateHosting({ - namespaceId: namespace.id, - input: body, - }); - - return makeApiSuccessResponse({ - data: HostingSchema.parse({ - ...updatedHosting, - namespaceId: prefixId(updatedHosting.namespaceId, "ns_"), - }), - headers, - }); - }, - { logging: { routeName: "PATCH /v1/namespace/[namespaceId]/hosting" } }, -); - -export const PUT = PATCH; - -export const DELETE = withNamespaceApiHandler( - async ({ namespace, headers }) => { - await deleteHosting({ namespaceId: namespace.id }); - - return new Response(null, { status: 204, headers }); - }, - { logging: { routeName: "DELETE /v1/namespace/[namespaceId]/hosting" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts deleted file mode 100644 index a626b1ab..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { normalizeId, prefixId } from "@agentset/utils"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; - -export const POST = withNamespaceApiHandler( - async ({ params, namespace, headers, organization }) => { - const jobId = normalizeId(params.jobId ?? "", "job_"); - if (!jobId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid job id", - }); - } - - const job = await reIngestJob({ - jobId, - namespaceId: namespace.id, - organizationId: namespace.organizationId, - plan: organization.plan, - }); - - return makeApiSuccessResponse({ - data: { - id: prefixId(job.id, "job_"), - }, - headers, - }); - }, - { - logging: { - routeName: - "POST /v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest", - }, - }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts deleted file mode 100644 index 8d162003..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { normalizeId, prefixId } from "@agentset/utils"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { IngestJobSchema } from "@/schemas/api/ingest-job"; -import { deleteIngestJob } from "@/services/ingest-jobs/delete"; - -import { db } from "@agentset/db/client"; - -export const GET = withNamespaceApiHandler( - async ({ params, namespace, headers }) => { - const jobId = normalizeId(params.jobId ?? "", "job_"); - if (!jobId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid job id", - }); - } - - const data = await db.ingestJob.findUnique({ - where: { - id: jobId, - namespaceId: namespace.id, - }, - }); - - if (!data) { - throw new AgentsetApiError({ - code: "not_found", - message: "Ingest job not found", - }); - } - - return makeApiSuccessResponse({ - data: IngestJobSchema.parse({ - ...data, - id: prefixId(data.id, "job_"), - namespaceId: prefixId(data.namespaceId, "ns_"), - }), - headers, - }); - }, - { - logging: { - routeName: "GET /v1/namespace/[namespaceId]/ingest-jobs/[jobId]", - }, - }, -); - -export const DELETE = withNamespaceApiHandler( - async ({ params, namespace, headers }) => { - const jobId = normalizeId(params.jobId ?? "", "job_"); - if (!jobId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid job id", - }); - } - - const data = await deleteIngestJob({ - jobId, - namespaceId: namespace.id, - organizationId: namespace.organizationId, - }); - - return makeApiSuccessResponse({ - data: IngestJobSchema.parse({ - ...data, - id: prefixId(data.id, "job_"), - namespaceId: prefixId(data.namespaceId, "ns_"), - }), - headers, - }); - }, - { - logging: { - routeName: "DELETE /v1/namespace/[namespaceId]/ingest-jobs/[jobId]", - }, - }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts deleted file mode 100644 index c7bcc987..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { prefixId } from "@agentset/utils"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { - createIngestJobSchema, - getIngestionJobsSchema, - IngestJobSchema, -} from "@/schemas/api/ingest-job"; -import { createIngestJob } from "@/services/ingest-jobs/create"; -import { getPaginationArgs, paginateResults } from "@/services/pagination"; - -import { db } from "@agentset/db/client"; -import { isFreePlan } from "@agentset/stripe/plans"; - -export const GET = withNamespaceApiHandler( - async ({ searchParams, namespace, tenantId, headers }) => { - const query = await getIngestionJobsSchema.parseAsync(searchParams); - - const { where, ...paginationArgs } = getPaginationArgs( - query, - { - orderBy: query.orderBy, - order: query.order, - }, - "job_", - ); - - const ingestJobs = await db.ingestJob.findMany({ - where: { - namespaceId: namespace.id, - tenantId, - ...(query.statuses && - query.statuses.length > 0 && { - status: { in: query.statuses }, - }), - ...where, - }, - ...paginationArgs, - }); - - const paginated = paginateResults( - query, - ingestJobs.map((job) => - IngestJobSchema.parse({ - ...job, - id: prefixId(job.id, "job_"), - namespaceId: prefixId(job.namespaceId, "ns_"), - }), - ), - ); - - return makeApiSuccessResponse({ - data: paginated.records, - pagination: paginated.pagination, - headers, - }); - }, - { logging: { routeName: "GET /v1/namespace/[namespaceId]/ingest-jobs" } }, -); - -export const POST = withNamespaceApiHandler( - async ({ req, namespace, tenantId, headers, organization }) => { - const body = await createIngestJobSchema.parseAsync( - await parseRequestBody(req), - ); - - if (isFreePlan(organization.plan) && body.config?.mode === "accurate") { - throw new AgentsetApiError({ - code: "bad_request", - message: "Accurate mode requires a pro subscription.", - }); - } - - const job = await createIngestJob({ - data: body, - organization, - namespaceId: namespace.id, - tenantId, - }); - - return makeApiSuccessResponse({ - data: IngestJobSchema.parse({ - ...job, - id: prefixId(job.id, "job_"), - namespaceId: prefixId(job.namespaceId, "ns_"), - }), - headers, - status: 201, - }); - }, - { logging: { routeName: "POST /v1/namespace/[namespaceId]/ingest-jobs" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts deleted file mode 100644 index 07ac88e6..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { prefixId } from "@agentset/utils"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { - NamespaceSchema, - updateNamespaceSchema, -} from "@/schemas/api/namespace"; -import { deleteNamespace } from "@/services/namespaces/delete"; -import { updateNamespace } from "@/services/namespaces/update"; - -export const GET = withNamespaceApiHandler( - async ({ namespace, headers }) => { - return makeApiSuccessResponse({ - data: NamespaceSchema.parse({ - ...namespace, - id: prefixId(namespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }), - headers, - }); - }, - { logging: { routeName: "GET /v1/namespace/[namespaceId]" } }, -); - -export const PATCH = withNamespaceApiHandler( - async ({ namespace, headers, req }) => { - const data = await updateNamespaceSchema.parseAsync( - await parseRequestBody(req), - ); - - const updatedNamespace = await updateNamespace({ - namespaceId: namespace.id, - organizationId: namespace.organizationId, - data, - }); - - return makeApiSuccessResponse({ - data: NamespaceSchema.parse({ - ...updatedNamespace, - id: prefixId(updatedNamespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }), - headers, - }); - }, - { logging: { routeName: "PATCH /v1/namespace/[namespaceId]" } }, -); - -export const PUT = PATCH; - -export const DELETE = withNamespaceApiHandler( - async ({ namespace, headers }) => { - // TODO: check apiScope - await deleteNamespace({ namespaceId: namespace.id }); - - return new Response(null, { status: 204, headers }); - }, - { logging: { routeName: "DELETE /v1/namespace/[namespaceId]" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts deleted file mode 100644 index 3ab2b591..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { - checkSearchLimit, - incrementOrganizationSearchUsage, -} from "@/lib/api/usage"; -import { parseRequestBody } from "@/lib/api/utils"; -import { queryVectorStoreSchema } from "@/schemas/api/query"; -import { searchNamespace } from "@/services/search"; - -export const preferredRegion = "iad1"; // make this closer to the DB - -export const POST = withNamespaceApiHandler( - async ({ req, namespace, tenantId, organization, headers }) => { - // TODO: set hard limits to prevent abuse - checkSearchLimit(organization); - - const body = await queryVectorStoreSchema.parseAsync( - await parseRequestBody(req), - ); - - const results = await searchNamespace({ - namespace, - tenantId, - options: body, - }); - - incrementOrganizationSearchUsage(organization.id, 1); - - return makeApiSuccessResponse({ - data: results, - headers, - }); - }, - { logging: { routeName: "POST /v1/namespace/[namespaceId]/search" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/batch/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/batch/route.ts deleted file mode 100644 index f78e5398..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/batch/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler/namespace"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { batchUploadSchema, UploadResultSchema } from "@/schemas/api/upload"; -import { createBatchUpload } from "@/services/uploads"; -import { z } from "zod/v4"; - -export const POST = withNamespaceApiHandler( - async ({ namespace, headers, req }) => { - const { files } = await batchUploadSchema.parseAsync( - await parseRequestBody(req), - ); - - const result = await createBatchUpload({ - namespaceId: namespace.id, - files, - }); - - if (!result.success) { - throw new AgentsetApiError({ - code: "internal_server_error", - message: result.error, - }); - } - - return makeApiSuccessResponse({ - data: z.array(UploadResultSchema).parse(result.data), - headers, - status: 201, - }); - }, - { logging: { routeName: "POST /v1/namespace/[namespaceId]/uploads/batch" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/route.ts deleted file mode 100644 index b5c4ea51..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler/namespace"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { uploadFileSchema, UploadResultSchema } from "@/schemas/api/upload"; -import { createUpload } from "@/services/uploads"; - -export const POST = withNamespaceApiHandler( - async ({ namespace, headers, req }) => { - const { fileName, contentType, fileSize } = - await uploadFileSchema.parseAsync(await parseRequestBody(req)); - - const result = await createUpload({ - namespaceId: namespace.id, - file: { fileName, contentType, fileSize }, - }); - - if (!result.success) { - throw new AgentsetApiError({ - code: "internal_server_error", - message: result.error, - }); - } - - return makeApiSuccessResponse({ - data: UploadResultSchema.parse(result.data), - headers, - status: 201, - }); - }, - { logging: { routeName: "POST /v1/namespace/[namespaceId]/uploads" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/warm-up/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/warm-up/route.ts deleted file mode 100644 index 1a75059d..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/warm-up/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; - -import { getNamespaceVectorStore } from "@agentset/engine"; - -export const POST = withNamespaceApiHandler( - async ({ namespace, headers, tenantId }) => { - const vectorStore = await getNamespaceVectorStore(namespace, tenantId); - const result = await vectorStore.warmCache(); - - if (result === "UNSUPPORTED") { - throw new AgentsetApiError({ - code: "bad_request", - message: "Warm cache is not supported for this vector store", - }); - } - - return makeApiSuccessResponse({ - data: { status: true }, - headers, - }); - }, - { logging: { routeName: "POST /v1/namespace/[namespaceId]/warm-up" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/namespace/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/route.ts deleted file mode 100644 index 3b97bd46..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { withApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { - createNamespaceSchema, - NamespaceSchema, -} from "@/schemas/api/namespace"; -import { createNamespace } from "@/services/namespaces/create"; - -import { NamespaceStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { prefixId } from "@agentset/utils"; - -export const GET = withApiHandler( - async ({ organization, headers }) => { - const namespaces = await db.namespace.findMany({ - where: { - organizationId: organization.id, - status: NamespaceStatus.ACTIVE, - }, - orderBy: { - createdAt: "asc", - }, - }); - - return makeApiSuccessResponse({ - data: namespaces.map((namespace) => - NamespaceSchema.parse({ - ...namespace, - id: prefixId(namespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }), - ), - headers, - }); - }, - { logging: { routeName: "GET /v1/namespace" } }, -); - -export const POST = withApiHandler( - async ({ organization, req, headers }) => { - const parsed = await createNamespaceSchema.parseAsync( - await parseRequestBody(req), - ); - - // TODO: check apiScope - const namespace = await createNamespace({ - organizationId: organization.id, - data: parsed, - }); - - return makeApiSuccessResponse({ - data: NamespaceSchema.parse({ - ...namespace, - id: prefixId(namespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }), - headers, - status: 201, - }); - }, - { logging: { routeName: "POST /v1/namespace" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/regenerate-secret/route.ts b/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/regenerate-secret/route.ts deleted file mode 100644 index c47fa54c..00000000 --- a/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/regenerate-secret/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { WebhookSchema } from "@/schemas/api/webhook"; -import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; - -import { prefixId } from "@agentset/utils"; - -export const POST = withApiHandler( - async ({ organization, params, headers }) => { - const webhook = await regenerateWebhookSecret({ - organizationId: organization.id, - webhookId: params.webhookId ?? "", - }); - - if (!webhook) { - throw new AgentsetApiError({ - code: "not_found", - message: "Webhook not found.", - }); - } - - return makeApiSuccessResponse({ - data: WebhookSchema.parse({ - ...webhook, - namespaceIds: (webhook.namespaceIds ?? []).map((id) => - prefixId(id, "ns_"), - ), - }), - headers, - }); - }, - { - logging: { - routeName: "POST /v1/webhooks/[webhookId]/regenerate-secret", - }, - }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/route.ts b/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/route.ts deleted file mode 100644 index 6f977654..00000000 --- a/apps/web/src/app/api/(public-api)/v1/webhooks/[webhookId]/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { withApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { - updateWebhookSchema, - WebhookDetailsSchema, - WebhookSchema, -} from "@/schemas/api/webhook"; -import { deleteWebhook } from "@/services/webhooks/delete"; -import { getWebhook } from "@/services/webhooks/get"; -import { applyWebhookUpdate } from "@/services/webhooks/update"; - -import type { WebhookProps } from "@agentset/webhooks"; -import { prefixId } from "@agentset/utils"; - -const webhookNotFoundError = () => - new AgentsetApiError({ - code: "not_found", - message: "Webhook not found.", - }); - -const prefixNamespaceIds = (webhook: WebhookProps) => ({ - ...webhook, - namespaceIds: (webhook.namespaceIds ?? []).map((id) => prefixId(id, "ns_")), -}); - -export const GET = withApiHandler( - async ({ organization, params, headers }) => { - const webhook = await getWebhook({ - organizationId: organization.id, - webhookId: params.webhookId ?? "", - }); - - if (!webhook) { - throw webhookNotFoundError(); - } - - return makeApiSuccessResponse({ - data: WebhookDetailsSchema.parse(prefixNamespaceIds(webhook)), - headers, - }); - }, - { logging: { routeName: "GET /v1/webhooks/[webhookId]" } }, -); - -export const PATCH = withApiHandler( - async ({ organization, params, headers, req }) => { - const body = await updateWebhookSchema.parseAsync( - await parseRequestBody(req), - ); - - const webhook = await applyWebhookUpdate({ - organizationId: organization.id, - plan: organization.plan, - webhookId: params.webhookId ?? "", - ...body, - }); - - return makeApiSuccessResponse({ - data: WebhookSchema.parse(prefixNamespaceIds(webhook)), - headers, - }); - }, - { logging: { routeName: "PATCH /v1/webhooks/[webhookId]" } }, -); - -export const PUT = PATCH; - -export const DELETE = withApiHandler( - async ({ organization, params, headers }) => { - const webhook = await deleteWebhook({ - organizationId: organization.id, - webhookId: params.webhookId ?? "", - }); - - if (!webhook) { - throw webhookNotFoundError(); - } - - return new Response(null, { status: 204, headers }); - }, - { logging: { routeName: "DELETE /v1/webhooks/[webhookId]" } }, -); diff --git a/apps/web/src/app/api/(public-api)/v1/webhooks/route.ts b/apps/web/src/app/api/(public-api)/v1/webhooks/route.ts deleted file mode 100644 index 540ad130..00000000 --- a/apps/web/src/app/api/(public-api)/v1/webhooks/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { withApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -import { parseRequestBody } from "@/lib/api/utils"; -import { - createWebhookSchema, - WebhookSchema, - WebhookSummarySchema, -} from "@/schemas/api/webhook"; -import { createWebhook } from "@/services/webhooks/create"; -import { listWebhooks } from "@/services/webhooks/list"; -import { requireWebhooksPlan } from "@/services/webhooks/plan"; - -import { normalizeId, prefixId } from "@agentset/utils"; - -export const GET = withApiHandler( - async ({ organization, headers }) => { - const webhooks = await listWebhooks({ organizationId: organization.id }); - - return makeApiSuccessResponse({ - data: webhooks.map((webhook) => - WebhookSummarySchema.parse({ - ...webhook, - namespaceIds: (webhook.namespaceIds ?? []).map((id) => - prefixId(id, "ns_"), - ), - }), - ), - headers, - }); - }, - { logging: { routeName: "GET /v1/webhooks" } }, -); - -export const POST = withApiHandler( - async ({ organization, req, headers }) => { - requireWebhooksPlan(organization.plan); - - const parsed = await createWebhookSchema.parseAsync( - await parseRequestBody(req), - ); - - const webhook = await createWebhook({ - ...parsed, - namespaceIds: parsed.namespaceIds?.map((id) => normalizeId(id, "ns_")), - organizationId: organization.id, - }); - - return makeApiSuccessResponse({ - data: WebhookSchema.parse({ - ...webhook, - namespaceIds: (webhook.namespaceIds ?? []).map((id) => - prefixId(id, "ns_"), - ), - }), - headers, - status: 201, - }); - }, - { logging: { routeName: "POST /v1/webhooks" } }, -); diff --git a/apps/web/src/server/orpc/internal/api-keys.ts b/apps/web/src/server/orpc/internal/api-keys.ts index d354bd63..ff598b4f 100644 --- a/apps/web/src/server/orpc/internal/api-keys.ts +++ b/apps/web/src/server/orpc/internal/api-keys.ts @@ -1,2 +1,112 @@ -// Stub — replaced by the oRPC migration fan-out. -export const apiKeysRouter = {}; +import { protectedProcedure } from "@/server/orpc/base"; +import { createApiKey, createApiKeySchema } from "@/services/api-key/create"; +import { deleteApiKey } from "@/services/api-key/delete"; +import { listApiKeys } from "@/services/api-key/list"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { Prisma } from "@agentset/db"; +import { db } from "@agentset/db/client"; + +export const apiKeysRouter = { + getApiKeys: protectedProcedure + .input( + z.object({ + orgId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + // make sure the user is a member of the org + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.orgId, + }, + select: { + id: true, + role: true, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + const apiKeys = await listApiKeys({ organizationId: input.orgId }); + + return apiKeys; + }), + getDefaultApiKey: protectedProcedure + .input(z.object({ orgId: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.orgId, + }, + select: { id: true }, + }); + + if (!member) { + throw new ORPCError("UNAUTHORIZED"); + } + + const apiKey = await db.organizationApiKey.findFirst({ + where: { + organizationId: input.orgId, + label: "Default API Key", + }, + select: { key: true }, + }); + + return apiKey?.key ?? null; + }), + createApiKey: protectedProcedure + .input(createApiKeySchema) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + const apiKey = await createApiKey(input); + + return apiKey; + }), + deleteApiKey: protectedProcedure + .input(z.object({ orgId: z.string(), id: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.orgId, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + try { + // scoped to the org so a member of one org can't delete another org's key + await deleteApiKey({ id: input.id, organizationId: input.orgId }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + throw new ORPCError("NOT_FOUND"); + } + + throw error; + } + + return { success: true }; + }), +}; diff --git a/apps/web/src/server/orpc/internal/billing.ts b/apps/web/src/server/orpc/internal/billing.ts index 6671897a..4b8bcaf1 100644 --- a/apps/web/src/server/orpc/internal/billing.ts +++ b/apps/web/src/server/orpc/internal/billing.ts @@ -1,2 +1,394 @@ -// Stub — replaced by the oRPC migration fan-out. -export const billingRouter = {}; +import type { ProtectedContext } from "@/server/orpc/base"; +import { getBaseUrl } from "@/lib/utils"; +import { protectedProcedure } from "@/server/orpc/base"; +import { ORPCError, os } from "@orpc/server"; +import { z } from "zod/v4"; + +import { db } from "@agentset/db/client"; +import { stripe } from "@agentset/stripe"; +import { + getStripeEnvironment, + isFreePlan, + isProPlan, + PRO_PLAN_METERED, +} from "@agentset/stripe/plans"; + +/** + * Port of the tRPC `organizationMiddleware`: input-dependent middleware that + * verifies membership and injects `context.organization`. + * + * tRPC merged chained `.input()` calls, so `{ orgId }` was implicit on every + * billing procedure; oRPC does NOT merge inputs, so each procedure's schema + * includes `orgId` explicitly and applies this middleware AFTER `.input()` as + * `.use(requireOrganization, (input) => input.orgId)`. + */ +const requireOrganization = os + .$context() + .middleware(async ({ context, next }, orgId: string) => { + const organization = await db.organization.findUnique({ + where: { + id: orgId, + members: { some: { userId: context.session.user.id } }, + }, + select: { + id: true, + slug: true, + stripeId: true, + }, + }); + + if (!organization) { + throw new ORPCError("NOT_FOUND", { + message: "Organization not found", + }); + } + + return next({ + context: { + organization, + }, + }); + }); + +const orgInputSchema = z.object({ + orgId: z.string(), +}); + +export const billingRouter = { + getCurrentPlan: protectedProcedure + .input(orgInputSchema) + .use(requireOrganization, (input) => input.orgId) + .handler(async ({ context }) => { + const org = await db.organization.findUnique({ + where: { + id: context.organization.id, + }, + select: { + plan: true, + pagesLimit: true, + totalPages: true, + totalDocuments: true, + totalIngestJobs: true, + billingCycleStart: true, + stripeId: true, + }, + }); + + return org!; + }), + getTrackedPages: protectedProcedure + .input(orgInputSchema) + .use(requireOrganization, (input) => input.orgId) + .handler(async ({ context }) => { + const org = await db.organization.findUnique({ + where: { id: context.organization.id }, + select: { + plan: true, + stripeId: true, + billingCycleStart: true, + }, + }); + + if (!org || isFreePlan(org.plan) || !org.stripeId) { + return { trackedPages: 0 }; + } + + const subscription = ( + await stripe.subscriptions.list({ + customer: org.stripeId, + status: "active", + limit: 1, + }) + ).data[0]; + + if (!subscription) { + return { trackedPages: 0 }; + } + + const item = subscription.items.data[0]; + const currentPeriodStart = + "current_period_start" in subscription + ? (subscription.current_period_start as number) + : item?.current_period_start; + + const currentPeriodEnd = + "current_period_end" in subscription + ? (subscription.current_period_end as number) + : item?.current_period_end; + + if (!currentPeriodStart || !currentPeriodEnd) { + return { trackedPages: 0 }; + } + + try { + const meters = await stripe.billing.meters.list({ status: "active" }); + const meter = meters.data.find( + (m) => m.event_name === PRO_PLAN_METERED.meterName, + ); + + if (!meter) { + return { trackedPages: 0 }; + } + + const summaries = await stripe.billing.meters.listEventSummaries( + meter.id, + { + customer: org.stripeId, + start_time: currentPeriodStart, + end_time: currentPeriodEnd, + }, + ); + + const trackedPages = summaries.data.reduce( + (sum, s) => sum + s.aggregated_value, + 0, + ); + + return { trackedPages }; + } catch (error) { + console.error("Error fetching tracked pages from Stripe:", error); + return { trackedPages: 0 }; + } + }), + upgrade: protectedProcedure + .input( + orgInputSchema.extend({ + plan: z.enum(["free", "pro"]), + period: z.enum(["monthly", "yearly"]), + baseUrl: z.string(), + }), + ) + .use(requireOrganization, (input) => input.orgId) + .handler(async ({ context, input }) => { + const { plan, period, baseUrl } = input; + + const planKey = plan.replace(" ", "+"); + const prices = await stripe.prices.list({ + lookup_keys: [`${planKey}_${period}`], + }); + const priceId = prices.data[0]!.id; + + const activeSubscription = context.organization.stripeId + ? await stripe.subscriptions + .list({ + customer: context.organization.stripeId, + status: "active", + }) + .then((res) => res.data[0]) + : null; + + // if the user has an active subscription, create billing portal to upgrade + if (context.organization.stripeId && activeSubscription) { + const { url } = await stripe.billingPortal.sessions.create({ + customer: context.organization.stripeId, + return_url: baseUrl, + flow_data: { + type: "subscription_update", + subscription_update: { + subscription: activeSubscription.id, + }, + }, + }); + + return { url }; + } else { + // For both new users and users with canceled subscriptions + const stripeSession = await stripe.checkout.sessions.create({ + ...(context.organization.stripeId + ? { + customer: context.organization.stripeId, + // need to pass this or Stripe will throw an error: https://git.new/kX4fi6B + customer_update: { + name: "auto", + address: "auto", + }, + } + : { + customer_email: context.session.user.email, + }), + billing_address_collection: "required", + success_url: `${getBaseUrl()}/${context.organization.slug}?upgraded=true&plan=${planKey}&period=${period}`, + cancel_url: baseUrl, + line_items: [ + { price: priceId, quantity: 1 }, + ...(isProPlan(plan) + ? [ + { + price: + PRO_PLAN_METERED[period].priceId[getStripeEnvironment()], + }, + ] + : []), + ], + allow_promotion_codes: true, + automatic_tax: { + enabled: true, + }, + tax_id_collection: { + enabled: true, + }, + mode: "subscription", + client_reference_id: context.organization.id, + metadata: { + agentsetCustomerId: context.session.user.id, + }, + }); + + return { sessionId: stripeSession.id }; + } + }), + invoices: protectedProcedure + .input(orgInputSchema) + .use(requireOrganization, (input) => input.orgId) + .handler(async ({ context }) => { + if (!context.organization.stripeId) { + return []; + } + + try { + const invoices = await stripe.invoices.list({ + customer: context.organization.stripeId, + }); + + return invoices.data.map((invoice) => { + return { + id: invoice.id, + total: invoice.amount_paid, + createdAt: new Date(invoice.created * 1000), + description: "Agentset subscription", + pdfUrl: invoice.invoice_pdf, + }; + }); + } catch (error) { + console.log(error); + return []; + } + }), + manage: protectedProcedure + .input(orgInputSchema) + .use(requireOrganization, (input) => input.orgId) + .handler(async ({ context }) => { + if (!context.organization.stripeId) { + throw new ORPCError("BAD_REQUEST", { + message: "No Stripe customer ID", + }); + } + + try { + const { url } = await stripe.billingPortal.sessions.create({ + customer: context.organization.stripeId, + return_url: `${getBaseUrl()}/${context.organization.slug}/billing`, + }); + return url; + } catch (error: any) { + throw new ORPCError("BAD_REQUEST", { + message: error?.raw?.message, + }); + } + }), + getPaymentMethods: protectedProcedure + .input(orgInputSchema) + .use(requireOrganization, (input) => input.orgId) + .handler(async ({ context }) => { + if (!context.organization.stripeId) { + throw new ORPCError("BAD_REQUEST", { + message: "No Stripe customer ID", + }); + } + + try { + const paymentMethods = await stripe.paymentMethods.list({ + customer: context.organization.stripeId, + }); + + // reorder to put ACH first + const ach = paymentMethods.data.find( + (method) => method.type === "us_bank_account", + ); + + return [ + ...(ach ? [ach] : []), + ...paymentMethods.data.filter((method) => method.id !== ach?.id), + ]; + } catch (error) { + console.error(error); + return []; + } + }), + addPaymentMethod: protectedProcedure + .input( + orgInputSchema.extend({ + method: z.enum(["card", "us_bank_account"]).optional(), + }), + ) + .use(requireOrganization, (input) => input.orgId) + .handler(async ({ context, input }) => { + if (!context.organization.stripeId) { + throw new ORPCError("BAD_REQUEST", { + message: "No Stripe customer ID", + }); + } + + if (!input.method) { + const { url } = await stripe.billingPortal.sessions.create({ + customer: context.organization.stripeId, + return_url: `${getBaseUrl()}/${context.organization.slug}/billing`, + flow_data: { + type: "payment_method_update", + }, + }); + + return url; + } + + const { url } = await stripe.checkout.sessions.create({ + mode: "setup", + customer: context.organization.stripeId, + payment_method_types: [input.method], + success_url: `${getBaseUrl()}/${context.organization.slug}/billing`, + cancel_url: `${getBaseUrl()}/${context.organization.slug}/billing`, + }); + + return url; + }), + cancel: protectedProcedure + .input(orgInputSchema) + .use(requireOrganization, (input) => input.orgId) + .handler(async ({ context }) => { + if (!context.organization.stripeId) { + throw new ORPCError("BAD_REQUEST", { + message: "No Stripe customer ID", + }); + } + + try { + const activeSubscription = await stripe.subscriptions + .list({ + customer: context.organization.stripeId, + status: "active", + }) + .then((res) => res.data[0]); + + if (!activeSubscription) + throw new ORPCError("BAD_REQUEST", { + message: "No active subscription", + }); + + const { url } = await stripe.billingPortal.sessions.create({ + customer: context.organization.stripeId, + return_url: `${getBaseUrl()}/${context.organization.slug}/billing`, + flow_data: { + type: "subscription_cancel", + subscription_cancel: { + subscription: activeSubscription.id, + }, + }, + }); + return url; + } catch (error: any) { + throw new ORPCError("BAD_REQUEST", { + message: error.raw.message, + }); + } + }), +}; diff --git a/apps/web/src/server/orpc/internal/documents.ts b/apps/web/src/server/orpc/internal/documents.ts index 2433210a..9f4cbd38 100644 --- a/apps/web/src/server/orpc/internal/documents.ts +++ b/apps/web/src/server/orpc/internal/documents.ts @@ -1,2 +1,192 @@ -// Stub — replaced by the oRPC migration fan-out. -export const documentsRouter = {}; +import { getDocumentsSchema } from "@/schemas/api/document"; +import { protectedProcedure } from "@/server/orpc/base"; +import { deleteDocument } from "@/services/documents/delete"; +import { getPaginationArgs, paginateResults } from "@/services/pagination"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { DocumentStatus } from "@agentset/db"; +import { db } from "@agentset/db/client"; +import { presignChunksDownloadUrl, presignGetUrl } from "@agentset/storage"; + +import { getNamespaceByUser } from "./helpers"; + +export const documentsRouter = { + all: protectedProcedure + .input( + getDocumentsSchema.extend({ + namespaceId: z.string(), + ingestJobId: z.string().optional(), + }), + ) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const { where, ...paginationArgs } = getPaginationArgs( + input, + { + orderBy: input.orderBy, + order: input.order, + }, + "doc_", + ); + + const documents = await db.document.findMany({ + where: { + namespaceId: namespace.id, + ...(input.ingestJobId && { ingestJobId: input.ingestJobId }), + ...(input.statuses && + input.statuses.length > 0 && { status: { in: input.statuses } }), + ...where, + }, + select: { + id: true, + name: true, + totalTokens: true, + totalChunks: true, + totalCharacters: true, + source: true, + totalPages: true, + documentProperties: true, + createdAt: true, + queuedAt: true, + completedAt: true, + failedAt: true, + error: true, + status: true, + }, + ...paginationArgs, + }); + + return paginateResults(input, documents); + }), + delete: protectedProcedure + .input( + z.object({ + documentId: z.string(), + namespaceId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const document = await db.document.findUnique({ + where: { + id: input.documentId, + namespaceId: namespace.id, + }, + select: { id: true, status: true }, + }); + + if (!document) { + throw new ORPCError("NOT_FOUND"); + } + + if ( + document.status === DocumentStatus.QUEUED_FOR_DELETE || + document.status === DocumentStatus.DELETING + ) { + throw new ORPCError("BAD_REQUEST"); + } + + const updatedDocument = await deleteDocument({ + documentId: input.documentId, + organizationId: namespace.organizationId, + }); + + return updatedDocument; + }), + getChunksDownloadUrl: protectedProcedure + .input( + z.object({ + documentId: z.string(), + namespaceId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const document = await db.document.findUnique({ + where: { + id: input.documentId, + namespaceId: namespace.id, + }, + select: { id: true, status: true }, + }); + + if (!document) { + throw new ORPCError("NOT_FOUND"); + } + + if (document.status !== DocumentStatus.COMPLETED) { + throw new ORPCError("BAD_REQUEST", { + message: "Chunks are only available for completed documents", + }); + } + + const url = await presignChunksDownloadUrl(namespace.id, document.id); + if (!url) { + throw new ORPCError("NOT_FOUND"); + } + + return { url }; + }), + getFileDownloadUrl: protectedProcedure + .input( + z.object({ + documentId: z.string(), + namespaceId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const document = await db.document.findUnique({ + where: { + id: input.documentId, + namespaceId: namespace.id, + }, + select: { id: true, name: true, source: true }, + }); + + if (!document) { + throw new ORPCError("NOT_FOUND"); + } + + if (document.source.type !== "MANAGED_FILE") { + throw new ORPCError("BAD_REQUEST", { + message: "File download is only available for managed files", + }); + } + + const { url } = await presignGetUrl(document.source.key, { + fileName: document.name ?? undefined, + }); + + return { url }; + }), +}; diff --git a/apps/web/src/server/orpc/internal/domains.ts b/apps/web/src/server/orpc/internal/domains.ts index a7cfc626..14c92820 100644 --- a/apps/web/src/server/orpc/internal/domains.ts +++ b/apps/web/src/server/orpc/internal/domains.ts @@ -1,2 +1,68 @@ -// Stub — replaced by the oRPC migration fan-out. -export const domainsRouter = {}; +import type { ProtectedContext } from "@/server/orpc/base"; +import { protectedProcedure } from "@/server/orpc/base"; +import { addDomain } from "@/services/domains/add"; +import { checkDomainStatus } from "@/services/domains/check-status"; +import { removeDomain } from "@/services/domains/remove"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { db } from "@agentset/db/client"; + +const commonInput = z.object({ + namespaceId: z.string(), +}); + +export type { DomainVerificationStatusProps } from "@/schemas/api/hosting"; + +const getHosting = async ( + ctx: Pick, + input: z.infer, +) => { + const hosting = await db.hosting.findFirst({ + where: { + namespace: { + id: input.namespaceId, + organization: { + members: { some: { userId: ctx.session.user.id } }, + }, + }, + }, + }); + + return hosting ?? null; +}; + +const getHostingOrThrow = async ( + ctx: Pick, + input: z.infer, +) => { + const hosting = await getHosting(ctx, input); + if (!hosting) { + throw new ORPCError("NOT_FOUND", { + message: "Hosting not found", + }); + } + + return hosting; +}; + +export const domainsRouter = { + add: protectedProcedure + .input(commonInput.extend({ domain: z.string() })) + .handler(async ({ context, input }) => { + const hosting = await getHostingOrThrow(context, input); + return addDomain({ hostingId: hosting.id, domain: input.domain }); + }), + checkStatus: protectedProcedure + .input(commonInput) + .handler(async ({ context, input }) => { + const hosting = await getHostingOrThrow(context, input); + return checkDomainStatus({ hostingId: hosting.id }); + }), + remove: protectedProcedure + .input(commonInput) + .handler(async ({ context, input }) => { + const hosting = await getHostingOrThrow(context, input); + return removeDomain({ hostingId: hosting.id }); + }), +}; diff --git a/apps/web/src/server/orpc/internal/hosting.ts b/apps/web/src/server/orpc/internal/hosting.ts index 8c4e580b..419e78c9 100644 --- a/apps/web/src/server/orpc/internal/hosting.ts +++ b/apps/web/src/server/orpc/internal/hosting.ts @@ -1,2 +1,73 @@ -// Stub — replaced by the oRPC migration fan-out. -export const hostingRouter = {}; +import type { ProtectedContext } from "@/server/orpc/base"; +import { updateHostingSchema } from "@/schemas/api/hosting"; +import { protectedProcedure } from "@/server/orpc/base"; +import { deleteHosting } from "@/services/hosting/delete"; +import { enableHosting } from "@/services/hosting/enable"; +import { getHosting } from "@/services/hosting/get"; +import { updateHosting } from "@/services/hosting/update"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { db } from "@agentset/db/client"; + +const commonInput = z.object({ namespaceId: z.string() }); + +const verifyNamespaceAccess = async ( + context: Pick, + namespaceId: string, +) => { + const namespace = await db.namespace.findFirst({ + where: { + id: namespaceId, + organization: { + members: { some: { userId: context.session.user.id } }, + }, + }, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND", { + message: "Namespace not found or you don't have access to it", + }); + } + + return namespace; +}; + +// TODO: only allow for pro users +export const hostingRouter = { + get: protectedProcedure + .input(commonInput) + .handler(async ({ context, input }) => { + await verifyNamespaceAccess(context, input.namespaceId); + return getHosting({ namespaceId: input.namespaceId }); + }), + enable: protectedProcedure + .input(commonInput) + .handler(async ({ context, input }) => { + await verifyNamespaceAccess(context, input.namespaceId); + + return enableHosting({ + namespaceId: input.namespaceId, + }); + }), + update: protectedProcedure + .input(commonInput.extend(updateHostingSchema.shape)) + .handler(async ({ context, input: { namespaceId, ...input } }) => { + await verifyNamespaceAccess(context, namespaceId); + + return updateHosting({ + namespaceId, + input, + }); + }), + delete: protectedProcedure + .input(commonInput) + .handler(async ({ context, input }) => { + await verifyNamespaceAccess(context, input.namespaceId); + + await deleteHosting({ namespaceId: input.namespaceId }); + + return { success: true }; + }), +}; diff --git a/apps/web/src/server/orpc/internal/ingest-jobs.ts b/apps/web/src/server/orpc/internal/ingest-jobs.ts index a308ca73..cd796b68 100644 --- a/apps/web/src/server/orpc/internal/ingest-jobs.ts +++ b/apps/web/src/server/orpc/internal/ingest-jobs.ts @@ -1,2 +1,154 @@ -// Stub — replaced by the oRPC migration fan-out. -export const ingestJobsRouter = {}; +import { + createIngestJobSchema, + getIngestionJobsSchema, +} from "@/schemas/api/ingest-job"; +import { protectedProcedure } from "@/server/orpc/base"; +import { createIngestJob } from "@/services/ingest-jobs/create"; +import { deleteIngestJob } from "@/services/ingest-jobs/delete"; +import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; +import { getPaginationArgs, paginateResults } from "@/services/pagination"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { db } from "@agentset/db/client"; + +import { getNamespaceByUser } from "./helpers"; + +export const ingestJobsRouter = { + all: protectedProcedure + .input(getIngestionJobsSchema.extend({ namespaceId: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const { where, ...paginationArgs } = getPaginationArgs( + input, + { + orderBy: input.orderBy, + order: input.order, + }, + "job_", + ); + + const ingestJobs = await db.ingestJob.findMany({ + where: { + namespaceId: input.namespaceId, + ...(input.statuses && + input.statuses.length > 0 && { + status: { in: input.statuses }, + }), + ...where, + }, + select: { + id: true, + status: true, + name: true, + tenantId: true, + completedAt: true, + failedAt: true, + error: true, + queuedAt: true, + createdAt: true, + }, + ...paginationArgs, + }); + + return paginateResults(input, ingestJobs); + }), + getConfig: protectedProcedure + .input(z.object({ jobId: z.string(), namespaceId: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const ingestJob = await db.ingestJob.findUnique({ + where: { + id: input.jobId, + namespaceId: namespace.id, + }, + select: { + config: true, + }, + }); + + if (!ingestJob) { + throw new ORPCError("NOT_FOUND"); + } + + return ingestJob.config; + }), + ingest: protectedProcedure + .input( + createIngestJobSchema.extend({ + namespaceId: z.string(), + }), + ) + .handler(async ({ context, input: { namespaceId, ...input } }) => { + const namespace = await getNamespaceByUser(context, { + id: namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const organization = await db.organization.findUnique({ + where: { id: namespace.organizationId }, + }); + + if (!organization) { + throw new ORPCError("NOT_FOUND"); + } + + return await createIngestJob({ + data: input, + organization, + namespaceId: namespace.id, + }); + }), + delete: protectedProcedure + .input(z.object({ jobId: z.string(), namespaceId: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + return await deleteIngestJob({ + jobId: input.jobId, + namespaceId: namespace.id, + organizationId: namespace.organizationId, + }); + }), + reIngest: protectedProcedure + .input(z.object({ jobId: z.string(), namespaceId: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + // the service fetches the org plan in parallel with the job lookup + return await reIngestJob({ + jobId: input.jobId, + namespaceId: namespace.id, + organizationId: namespace.organizationId, + }); + }), +}; diff --git a/apps/web/src/server/orpc/internal/namespaces.ts b/apps/web/src/server/orpc/internal/namespaces.ts index 57399170..6bdd7729 100644 --- a/apps/web/src/server/orpc/internal/namespaces.ts +++ b/apps/web/src/server/orpc/internal/namespaces.ts @@ -1,2 +1,277 @@ -// Stub — replaced by the oRPC migration fan-out. -export const namespacesRouter = {}; +import type { ProtectedContext } from "@/server/orpc/base"; +import { createNamespaceSchema } from "@/schemas/api/namespace"; +import { protectedProcedure } from "@/server/orpc/base"; +import { createNamespace } from "@/services/namespaces/create"; +import { deleteNamespace } from "@/services/namespaces/delete"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { NamespaceStatus } from "@agentset/db"; +import { db } from "@agentset/db/client"; +import { getDemoTemplate } from "@agentset/demo"; +import { triggerSeedDemoNamespace } from "@agentset/jobs"; + +const validateIsMember = async ( + ctx: Pick, + orgId: string | { slug: string }, + roles?: string[], +) => { + const member = await db.member.findFirst({ + where: { + userId: ctx.session.user.id, + ...(typeof orgId === "string" + ? { organizationId: orgId } + : { + organization: { + slug: orgId.slug, + }, + }), + }, + select: { + id: true, + role: true, + organizationId: true, + }, + }); + + if (!member) { + throw new ORPCError("UNAUTHORIZED"); + } + + if (roles && !roles.includes(member.role)) { + throw new ORPCError("UNAUTHORIZED"); + } + + return member; +}; + +export const namespacesRouter = { + getOrgNamespaces: protectedProcedure + .input( + z.object({ + slug: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const member = await validateIsMember(context, { slug: input.slug }); + + const namespaces = await db.namespace.findMany({ + where: { + organizationId: member.organizationId, + status: NamespaceStatus.ACTIVE, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return namespaces; + }), + getNamespaceBySlug: protectedProcedure + .input(z.object({ orgSlug: z.string(), slug: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await db.namespace.findFirst({ + where: { + slug: input.slug, + organization: { + slug: input.orgSlug, + members: { some: { userId: context.session.user.id } }, + }, + status: NamespaceStatus.ACTIVE, + }, + }); + + return namespace; + }), + getOnboardingStatus: protectedProcedure + .input(z.object({ orgSlug: z.string(), slug: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await db.namespace.findFirst({ + where: { + slug: input.slug, + organization: { + slug: input.orgSlug, + members: { some: { userId: context.session.user.id } }, + }, + status: NamespaceStatus.ACTIVE, + }, + select: { + totalIngestJobs: true, + totalPlaygroundUsage: true, + organization: { + select: { + apiKeys: { + take: 1, + select: { + id: true, + }, + }, + }, + }, + }, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + return { + ingestDocuments: namespace.totalIngestJobs > 0, + playground: namespace.totalPlaygroundUsage > 0, + createApiKey: namespace.organization.apiKeys.length > 0, + }; + }), + checkSlug: protectedProcedure + .input( + z.object({ + orgId: z.string(), + slug: z.string(), + }), + ) + .handler(async ({ input }) => { + const namespace = await db.namespace.findUnique({ + where: { + organizationId_slug: { + slug: input.slug, + organizationId: input.orgId, + }, + }, + }); + + return !!namespace; + }), + createNamespace: protectedProcedure + .input( + createNamespaceSchema.extend( + z.object({ + orgId: z.string(), + }).shape, + ), + ) + .handler(async ({ context, input: { orgId, ...data } }) => { + await validateIsMember(context, orgId, ["admin", "owner"]); + + return await createNamespace({ + organizationId: orgId, + data, + }); + }), + createDemoNamespace: protectedProcedure + .input( + z.object({ + orgId: z.string(), + templateId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + await validateIsMember(context, input.orgId, ["admin", "owner"]); + + const template = getDemoTemplate(input.templateId); + if (!template) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid template ID", + }); + } + + const organization = await db.organization.findUnique({ + where: { id: input.orgId }, + select: { id: true, plan: true }, + }); + if (!organization) { + throw new ORPCError("NOT_FOUND"); + } + + let slug: string = template.id; + let suffix = 2; + while (true) { + const existing = await db.namespace.findUnique({ + where: { + organizationId_slug: { + organizationId: input.orgId, + slug, + }, + }, + select: { id: true }, + }); + + if (!existing) break; + + slug = `${template.id}-${suffix}`; + suffix += 1; + } + + const [namespace] = await db.$transaction([ + db.namespace.create({ + data: { + name: template.name, + slug, + demoId: template.id, + organizationId: input.orgId, + embeddingConfig: { + provider: "MANAGED_OPENAI", + model: "text-embedding-3-large", + }, + vectorStoreConfig: { + provider: "MANAGED_TURBOPUFFER", + }, + }, + }), + db.organization.update({ + where: { id: input.orgId }, + data: { + totalNamespaces: { increment: 1 }, + }, + }), + ]); + + await triggerSeedDemoNamespace( + { + namespaceId: namespace.id, + organizationId: organization.id, + templateId: template.id, + }, + organization.plan, + ); + + return namespace; + }), + deleteNamespace: protectedProcedure + .input( + z.object({ + namespaceId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const namespace = await db.namespace.findFirst({ + where: { + id: input.namespaceId, + organization: { + members: { + some: { + userId: context.session.user.id, + role: { + in: ["admin", "owner"], + }, + }, + }, + }, + status: NamespaceStatus.ACTIVE, + }, + select: { + id: true, + organizationId: true, + }, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND", { + message: + "Namespace not found or you don't have permission to delete it", + }); + } + + await deleteNamespace({ namespaceId: namespace.id }); + + return { success: true }; + }), +}; diff --git a/apps/web/src/server/orpc/internal/organizations.ts b/apps/web/src/server/orpc/internal/organizations.ts index 6ef7df6c..4e80c7dc 100644 --- a/apps/web/src/server/orpc/internal/organizations.ts +++ b/apps/web/src/server/orpc/internal/organizations.ts @@ -1,2 +1,130 @@ -// Stub — replaced by the oRPC migration fan-out. -export const organizationsRouter = {}; +import { protectedProcedure } from "@/server/orpc/base"; +import { deleteOrganization } from "@/services/organizations/delete"; +import { getOrganizationMembers } from "@/services/organizations/members"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { OrganizationStatus } from "@agentset/db"; +import { db } from "@agentset/db/client"; + +export const organizationsRouter = { + all: protectedProcedure.handler(async ({ context }) => { + const orgs = await db.organization.findMany({ + where: { + members: { + some: { + userId: context.session.user.id, + }, + }, + status: OrganizationStatus.ACTIVE, + }, + select: { + id: true, + name: true, + slug: true, + plan: true, + logo: true, + namespaces: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }); + + return orgs; + }), + getBySlug: protectedProcedure + .input(z.object({ slug: z.string() })) + .handler(async ({ context, input }) => { + const org = await db.organization.findUnique({ + where: { + slug: input.slug, + members: { + some: { + userId: context.session.user.id, + }, + }, + }, + include: { + members: { + where: { + userId: context.session.user.id, + }, + take: 1, + select: { + id: true, + role: true, + }, + }, + }, + }); + + if (!org) { + throw new ORPCError("NOT_FOUND"); + } + + const { members } = org; + const isAdmin = + members[0]?.role === "admin" || members[0]?.role === "owner"; + + return { + ...org, + isAdmin, + isOwner: members[0]?.role === "owner", + currentMemberId: members[0]?.id, + }; + }), + members: protectedProcedure + .input( + z.object({ + organizationId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const members = await getOrganizationMembers({ + organizationId: input.organizationId, + userId: context.session.user.id, + }); + + return members; + }), + delete: protectedProcedure + .input( + z.object({ + organizationId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const org = await db.organization.findUnique({ + where: { + id: input.organizationId, + members: { + some: { + userId: context.session.user.id, + role: { in: ["admin", "owner"] }, + }, + }, + }, + }); + + if (!org) { + throw new ORPCError("UNAUTHORIZED", { + message: "You are not authorized to delete this organization", + }); + } + + if (org.status === OrganizationStatus.DELETING) { + throw new ORPCError("BAD_REQUEST", { + message: "Organization is already being deleted", + }); + } + + await deleteOrganization({ organizationId: org.id }); + }), +}; diff --git a/apps/web/src/server/orpc/internal/search.ts b/apps/web/src/server/orpc/internal/search.ts index fabadaef..4a6f9b7c 100644 --- a/apps/web/src/server/orpc/internal/search.ts +++ b/apps/web/src/server/orpc/internal/search.ts @@ -1,2 +1,60 @@ -// Stub — replaced by the oRPC migration fan-out. -export const searchRouter = {}; +import { incrementSearchUsage } from "@/lib/api/usage"; +import { protectedProcedure } from "@/server/orpc/base"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { + getNamespaceEmbeddingModel, + getNamespaceVectorStore, + queryVectorStore, +} from "@agentset/engine"; +import { rerankerSchema } from "@agentset/validation"; + +import { getNamespaceByUser } from "./helpers"; + +const chunkExplorerInputSchema = z.object({ + namespaceId: z.string(), + query: z.string().min(1), + topK: z.number().min(1).max(100), + rerank: z.boolean(), + rerankModel: rerankerSchema, + rerankLimit: z.number().min(1).max(100), + filter: z.record(z.string(), z.any()).optional(), +}); + +export const searchRouter = { + search: protectedProcedure + .input(chunkExplorerInputSchema) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const [embeddingModel, vectorStore] = await Promise.all([ + getNamespaceEmbeddingModel(namespace, "query"), + getNamespaceVectorStore(namespace), + ]); + + const queryResult = await queryVectorStore({ + query: input.query, + topK: input.topK, + filter: input.filter, + includeMetadata: true, + rerank: input.rerank + ? { model: input.rerankModel, limit: input.rerankLimit } + : false, + embeddingModel, + vectorStore, + consistency: "strong", + }); + + // Track search usage + incrementSearchUsage(namespace.id, 1); + + return queryResult.results; + }), +}; diff --git a/apps/web/src/server/orpc/internal/uploads.ts b/apps/web/src/server/orpc/internal/uploads.ts index 810352d9..0a7a16d1 100644 --- a/apps/web/src/server/orpc/internal/uploads.ts +++ b/apps/web/src/server/orpc/internal/uploads.ts @@ -1,2 +1,70 @@ -// Stub — replaced by the oRPC migration fan-out. -export const uploadsRouter = {}; +import { batchUploadSchema, uploadFileSchema } from "@/schemas/api/upload"; +import { protectedProcedure } from "@/server/orpc/base"; +import { createBatchUpload, createUpload } from "@/services/uploads"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { getNamespaceByUser } from "./helpers"; + +export const uploadsRouter = { + getPresignedUrl: protectedProcedure + .input( + uploadFileSchema.extend({ + namespaceId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const ns = await getNamespaceByUser(context, { id: input.namespaceId }); + + if (!ns) { + throw new ORPCError("NOT_FOUND", { + message: "Namespace not found", + }); + } + + const result = await createUpload({ + namespaceId: ns.id, + file: { + fileName: input.fileName, + contentType: input.contentType, + fileSize: input.fileSize, + }, + }); + + if (!result.success) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: result.error, + }); + } + + return result.data; + }), + getPresignedUrls: protectedProcedure + .input( + batchUploadSchema.extend({ + namespaceId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const ns = await getNamespaceByUser(context, { id: input.namespaceId }); + + if (!ns) { + throw new ORPCError("NOT_FOUND", { + message: "Namespace not found", + }); + } + + const result = await createBatchUpload({ + namespaceId: ns.id, + files: input.files, + }); + + if (!result.success) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: result.error, + }); + } + + return result.data; + }), +}; diff --git a/apps/web/src/server/orpc/internal/webhooks.ts b/apps/web/src/server/orpc/internal/webhooks.ts index 7062b1a5..95056c7d 100644 --- a/apps/web/src/server/orpc/internal/webhooks.ts +++ b/apps/web/src/server/orpc/internal/webhooks.ts @@ -1,2 +1,351 @@ -// Stub — replaced by the oRPC migration fan-out. -export const webhooksRouter = {}; +import { samplePayload } from "@/lib/webhook/sample-events/payload"; +import { protectedProcedure } from "@/server/orpc/base"; +import { createWebhook } from "@/services/webhooks/create"; +import { deleteWebhook } from "@/services/webhooks/delete"; +import { getWebhook } from "@/services/webhooks/get"; +import { listWebhooks } from "@/services/webhooks/list"; +import { requireWebhooksPlan } from "@/services/webhooks/plan"; +import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; +import { toggleWebhook } from "@/services/webhooks/toggle"; +import { updateWebhook } from "@/services/webhooks/update"; +import { ORPCError } from "@orpc/server"; +import { nanoid } from "nanoid"; +import { z } from "zod/v4"; + +import { db } from "@agentset/db/client"; +import { triggerSendWebhook } from "@agentset/jobs"; +import { getWebhookEvents } from "@agentset/tinybird"; +import { + createWebhookSchema, + updateWebhookSchema, + WEBHOOK_EVENT_ID_PREFIX, + WEBHOOK_TRIGGERS, + webhookPayloadSchema, +} from "@agentset/webhooks"; + +export const webhooksRouter = { + // List all webhooks for an organization + list: protectedProcedure + .input(z.object({ organizationId: z.string() })) + .handler(async ({ context, input }) => { + // Verify user is member of org + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member) { + throw new ORPCError("UNAUTHORIZED"); + } + + return listWebhooks({ organizationId: input.organizationId }); + }), + + // Get a single webhook by ID + get: protectedProcedure + .input(z.object({ organizationId: z.string(), webhookId: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member) { + throw new ORPCError("UNAUTHORIZED"); + } + + const webhook = await getWebhook({ + organizationId: input.organizationId, + webhookId: input.webhookId, + }); + + if (!webhook) { + throw new ORPCError("NOT_FOUND"); + } + + return webhook; + }), + + // Get webhook events from Tinybird + getEvents: protectedProcedure + .input(z.object({ organizationId: z.string(), webhookId: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member) { + throw new ORPCError("UNAUTHORIZED"); + } + + // Verify webhook belongs to org + const webhook = await db.webhook.findUnique({ + where: { + id: input.webhookId, + organizationId: input.organizationId, + }, + select: { id: true }, + }); + + if (!webhook) { + throw new ORPCError("NOT_FOUND"); + } + + const events = await getWebhookEvents({ webhookId: input.webhookId }); + return events.data; + }), + + // Create a new webhook + create: protectedProcedure + .input(createWebhookSchema.extend({ organizationId: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + include: { + organization: { + select: { plan: true }, + }, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + // Check pro plan requirement + requireWebhooksPlan(member.organization.plan); + + return createWebhook({ + name: input.name, + url: input.url, + secret: input.secret, + triggers: input.triggers, + namespaceIds: input.namespaceIds, + organizationId: input.organizationId, + }); + }), + + // Update a webhook + update: protectedProcedure + .input( + updateWebhookSchema.extend({ + organizationId: z.string(), + webhookId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + include: { + organization: { + select: { plan: true }, + }, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + requireWebhooksPlan(member.organization.plan); + + const webhook = await updateWebhook({ + organizationId: input.organizationId, + webhookId: input.webhookId, + name: input.name, + url: input.url, + secret: input.secret, + triggers: input.triggers, + namespaceIds: input.namespaceIds, + }); + + if (!webhook) { + throw new ORPCError("NOT_FOUND"); + } + + return webhook; + }), + + // Delete a webhook + delete: protectedProcedure + .input(z.object({ organizationId: z.string(), webhookId: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + const webhook = await deleteWebhook({ + organizationId: input.organizationId, + webhookId: input.webhookId, + }); + + if (!webhook) { + throw new ORPCError("NOT_FOUND"); + } + + return { success: true }; + }), + + // Toggle webhook enabled/disabled + toggle: protectedProcedure + .input(z.object({ organizationId: z.string(), webhookId: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + const updatedWebhook = await toggleWebhook({ + organizationId: input.organizationId, + webhookId: input.webhookId, + }); + + if (!updatedWebhook) { + throw new ORPCError("NOT_FOUND"); + } + + return { disabledAt: updatedWebhook.disabledAt }; + }), + + // Send test webhook event + sendTest: protectedProcedure + .input( + z.object({ + organizationId: z.string(), + webhookId: z.string(), + trigger: z.enum(WEBHOOK_TRIGGERS), + }), + ) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + const webhook = await db.webhook.findUnique({ + where: { + id: input.webhookId, + organizationId: input.organizationId, + }, + select: { + id: true, + url: true, + secret: true, + }, + }); + + if (!webhook) { + throw new ORPCError("NOT_FOUND"); + } + + const payload = webhookPayloadSchema.parse({ + id: `${WEBHOOK_EVENT_ID_PREFIX}${nanoid(25)}`, + event: input.trigger, + createdAt: new Date().toISOString(), + data: samplePayload[input.trigger], + }); + + await triggerSendWebhook({ + webhookId: webhook.id, + eventId: payload.id, + event: input.trigger, + url: webhook.url, + secret: webhook.secret, + payload, + }); + + return { success: true }; + }), + + // Generate a new secret for a webhook + regenerateSecret: protectedProcedure + .input(z.object({ organizationId: z.string(), webhookId: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member || (member.role !== "admin" && member.role !== "owner")) { + throw new ORPCError("UNAUTHORIZED"); + } + + const updatedWebhook = await regenerateWebhookSecret({ + organizationId: input.organizationId, + webhookId: input.webhookId, + }); + + if (!updatedWebhook) { + throw new ORPCError("NOT_FOUND"); + } + + return { secret: updatedWebhook.secret }; + }), + + // Get namespaces for webhook namespace selector + getNamespaces: protectedProcedure + .input(z.object({ organizationId: z.string() })) + .handler(async ({ context, input }) => { + const member = await db.member.findFirst({ + where: { + userId: context.session.user.id, + organizationId: input.organizationId, + }, + }); + + if (!member) { + throw new ORPCError("UNAUTHORIZED"); + } + + const namespaces = await db.namespace.findMany({ + where: { + organizationId: input.organizationId, + status: "ACTIVE", + }, + select: { + id: true, + name: true, + slug: true, + }, + orderBy: { + name: "asc", + }, + }); + + return namespaces; + }), +}; diff --git a/apps/web/src/server/orpc/public/api-keys.ts b/apps/web/src/server/orpc/public/api-keys.ts index d354bd63..7adb8d01 100644 --- a/apps/web/src/server/orpc/public/api-keys.ts +++ b/apps/web/src/server/orpc/public/api-keys.ts @@ -1,2 +1,151 @@ -// Stub — replaced by the oRPC migration fan-out. -export const apiKeysRouter = {}; +import { AgentsetApiError } from "@/lib/api/errors"; +import { + ApiKeySchema, + createApiKeyBodySchema, + CreatedApiKeySchema, +} from "@/schemas/api/api-key"; +import { publicApi, successSchema } from "@/server/orpc/base"; +import { createApiKey } from "@/services/api-key/create"; +import { deleteApiKey } from "@/services/api-key/delete"; +import { listApiKeys } from "@/services/api-key/list"; +import { z } from "zod/v4"; + +import { prefixId } from "@agentset/utils"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const keyIdPathSchema = z.string().meta({ + examples: ["cm4x1q2z90000abcd1234efgh"], + description: "The id of the API key.", + param: { + in: "path", + name: "keyId", + id: "ApiKeyIdRef", + }, +}); + +const list = publicApi + .route({ + method: "GET", + path: "/api-keys", + operationId: "listApiKeys", + summary: "Retrieve a list of API keys", + description: + "Retrieve a list of API keys for the authenticated organization. The key material is never returned.", + tags: ["API Keys"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "list", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const apiKeys = await agentset.apiKeys.list(); +console.log(apiKeys); +`, + { isNs: false }, + ), + }), + }) + .output(successSchema(z.array(ApiKeySchema))) + .handler(async ({ context }) => { + const apiKeys = await listApiKeys({ + organizationId: context.organization.id, + }); + + return { + success: true as const, + data: apiKeys.map((apiKey) => ({ + ...apiKey, + organizationId: prefixId(apiKey.organizationId, "org_"), + })), + }; + }); + +const create = publicApi + .route({ + method: "POST", + path: "/api-keys", + successStatus: 201, + operationId: "createApiKey", + summary: "Create an API key", + description: + "Create an API key for the authenticated organization. The full key is only returned once, at creation time.", + tags: ["API Keys"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "create", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const apiKey = await agentset.apiKeys.create({ + label: "My API Key", +}); +console.log(apiKey.key); +`, + { isNs: false }, + ), + }), + }) + .input(createApiKeyBodySchema) + .output(successSchema(CreatedApiKeySchema)) + .handler(async ({ context, input }) => { + // TODO: check apiScope + const apiKey = await createApiKey({ + organizationId: context.organization.id, + label: input.label, + scope: input.scope, + }); + + return { + success: true as const, + data: { + ...apiKey, + organizationId: prefixId(apiKey.organizationId, "org_"), + }, + }; + }); + +const remove = publicApi + .route({ + method: "DELETE", + path: "/api-keys/{keyId}", + successStatus: 204, + operationId: "deleteApiKey", + summary: "Delete an API key", + description: + "Delete an API key for the authenticated organization. The key is revoked immediately.", + tags: ["API Keys"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "delete", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +await agentset.apiKeys.delete("cm4x1q2z90000abcd1234efgh"); +console.log("API key deleted"); +`, + { isNs: false }, + ), + }), + }) + .input(z.object({ keyId: keyIdPathSchema })) + .output(z.void()) + .handler(async ({ context, input }) => { + const keyId = input.keyId; + if (!keyId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid API key ID.", + }); + } + + // scoped to the organization; a missing key throws P2025 which maps to a 404 + await deleteApiKey({ id: keyId, organizationId: context.organization.id }); + }); + +export const apiKeysRouter = { + list, + create, + delete: remove, +}; diff --git a/apps/web/src/server/orpc/public/chat.ts b/apps/web/src/server/orpc/public/chat.ts index f6b1a261..5da5444f 100644 --- a/apps/web/src/server/orpc/public/chat.ts +++ b/apps/web/src/server/orpc/public/chat.ts @@ -1,2 +1,142 @@ -// Stub — replaced by the oRPC migration fan-out. -export const chatRouter = {}; +import { + checkSearchLimit, + incrementOrganizationSearchUsage, +} from "@/lib/api/usage"; +import { generateChat, streamChat } from "@/lib/chat"; +import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { chatResponseSchema, chatSchema } from "@/schemas/api/chat"; +import { + publicApi, + requireNamespace, + successSchema, +} from "@/server/orpc/base"; +import { toModelMessages } from "@/services/chat"; +import { type } from "@orpc/server"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; + +import type { QueryVectorStoreResult } from "@agentset/engine"; + +import { makeCodeSamples, ts } from "./code-samples"; + +/** + * The JSON (non-stream) response body, documented in the spec override — + * the output is a `type<>()` passthrough because the handler either returns + * a raw `ReadableStream` (AI SDK SSE) or an unparsed JSON envelope, exactly + * like the legacy route. + */ +const chatSuccessJsonSchema = () => { + const [, jsonSchema] = new ZodToJsonSchemaConverter().convert( + successSchema(chatResponseSchema), + { strategy: "output" }, + ); + return jsonSchema; +}; + +const execute = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/chat", + operationId: "chat", + summary: "Chat with a namespace", + description: + "Generate an answer to a conversation using the documents in a namespace (RAG). Supports `normal`, `agentic`, and `deepResearch` modes. When `stream` is `true`, the response is a `text/event-stream` of AI SDK UI message parts (including a `data-agentset-sources` part with the retrieved chunks) instead of the JSON envelope documented below.", + tags: ["Chat"], + outputStructure: "detailed", + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "execute", + "x-speakeasy-group": "chat", + security: [{ token: [] }], + responses: { + ...current.responses, + "200": { + description: + "The generated answer and the sources used to generate it", + content: { + "application/json": { + schema: chatSuccessJsonSchema() as never, + }, + }, + }, + }, + ...makeCodeSamples(ts` +const result = await ns.chat({ + messages: [{ role: "user", content: "What is machine learning?" }], + topK: 20, + rerank: true, +}); +console.log(result.message.content); +`), + }), + }) + .input(chatSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output( + type< + | { + status?: number; + headers?: Record; + body: ReadableStream; + } + | { + body: { + success: true; + data: { + message: { role: "assistant"; content: string }; + sources?: QueryVectorStoreResult["results"]; + }; + }; + } + >(), + ) + .handler(async ({ context, input }) => { + // TODO: set hard limits to prevent abuse + checkSearchLimit(context.organization); + + const messages = toModelMessages(input.messages); + + const onUsageIncrement = (queries: number) => { + incrementOrganizationSearchUsage(context.organization.id, queries); + }; + + if (input.stream) { + const res = await streamChat({ + namespace: context.namespace, + tenantId: context.tenantId, + messages, + options: input, + headers: context.resHeaders, + onUsageIncrement, + }); + + // the ReadableStream body passes through oRPC unserialized, preserving + // the AI SDK SSE wire format byte-for-byte + return { + status: 200, + headers: Object.fromEntries(res.headers.entries()), + body: res.body!, + }; + } + + const { text, sources } = await generateChat({ + namespace: context.namespace, + tenantId: context.tenantId, + messages, + options: input, + onUsageIncrement, + }); + + return { + body: { + success: true as const, + data: { + message: { role: "assistant" as const, content: text }, + sources, + }, + }, + }; + }); + +export const chatRouter = { + execute, +}; diff --git a/apps/web/src/server/orpc/public/documents.ts b/apps/web/src/server/orpc/public/documents.ts index 2433210a..b1d727c6 100644 --- a/apps/web/src/server/orpc/public/documents.ts +++ b/apps/web/src/server/orpc/public/documents.ts @@ -1,2 +1,321 @@ -// Stub — replaced by the oRPC migration fan-out. -export const documentsRouter = {}; +import { + documentIdPathSchema, + namespaceIdPathSchema, +} from "@/openapi/v1/utils"; +import { DocumentSchema, getDocumentsSchema } from "@/schemas/api/document"; +import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { queueDocumentDeletion } from "@/services/documents/delete"; +import { + getDocumentChunksDownloadUrl, + getDocumentFileDownloadUrl, +} from "@/services/documents/download"; +import { getDocumentOrThrow } from "@/services/documents/get"; +import { getPaginationArgs, paginateResults } from "@/services/pagination"; +import { type } from "@orpc/server"; +import { z } from "zod/v4"; + +import { db } from "@agentset/db/client"; +import { normalizeId, prefixId } from "@agentset/utils"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const list = publicApi + .route({ + method: "GET", + path: "/namespace/{namespaceId}/documents", + operationId: "listDocuments", + summary: "Retrieve a list of documents", + description: + "Retrieve a paginated list of documents for the authenticated organization.", + tags: ["Documents"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "list", + "x-speakeasy-pagination": { + type: "cursor", + inputs: [ + { + name: "cursor", + in: "parameters", + type: "cursor", + }, + ], + outputs: { + nextCursor: "$.pagination.nextCursor", + }, + }, + security: [{ token: [] }], + ...makeCodeSamples(ts` +const docs = await ns.documents.all(); +console.log(docs); +`), + }), + }) + .input(getDocumentsSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(z.array(DocumentSchema), { hasPagination: true })) + .handler(async ({ context, input }) => { + // For backward pagination we scan in the opposite direction, then reverse results. + const { where, ...paginationArgs } = getPaginationArgs( + input, + { + orderBy: input.orderBy, + order: input.order, + }, + "doc_", + ); + + const documents = await db.document.findMany({ + where: { + tenantId: context.tenantId, + namespaceId: context.namespace.id, + ...(input.ingestJobId && { + ingestJobId: normalizeId(input.ingestJobId, "job_"), + }), + ...(input.statuses && + input.statuses.length > 0 && { status: { in: input.statuses } }), + ...where, + }, + ...paginationArgs, + }); + + const paginated = paginateResults( + input, + documents.map((doc) => ({ + ...doc, + ingestJobId: prefixId(doc.ingestJobId, "job_"), + id: prefixId(doc.id, "doc_"), + })), + ); + + return { + success: true as const, + data: paginated.records, + pagination: paginated.pagination, + }; + }); + +const get = publicApi + .route({ + method: "GET", + path: "/namespace/{namespaceId}/documents/{documentId}", + operationId: "getDocument", + summary: "Retrieve a document", + description: "Retrieve the info for a document.", + tags: ["Documents"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "get", + security: [{ token: [] }], + ...makeCodeSamples(ts` +const document = await ns.documents.get("doc_123"); +console.log(document); +`), + }), + }) + .input( + z.object({ + namespaceId: namespaceIdPathSchema, + documentId: documentIdPathSchema, + }), + ) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(DocumentSchema)) + .handler(async ({ context, input }) => { + const doc = await getDocumentOrThrow({ + namespaceId: context.namespace.id, + documentId: input.documentId, + tenantId: context.tenantId, + }); + + return { + success: true as const, + data: { + ...doc, + id: prefixId(doc.id, "doc_"), + ingestJobId: prefixId(doc.ingestJobId, "job_"), + }, + }; + }); + +const del = publicApi + .route({ + method: "DELETE", + path: "/namespace/{namespaceId}/documents/{documentId}", + operationId: "deleteDocument", + summary: "Delete a document", + description: "Delete a document for the authenticated organization.", + tags: ["Documents"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "delete", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples(ts` +await ns.documents.delete("doc_123"); +console.log("Document queued for deletion"); +`), + }), + }) + .input( + z.object({ + namespaceId: namespaceIdPathSchema, + documentId: documentIdPathSchema, + }), + ) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(DocumentSchema)) + .handler(async ({ context, input }) => { + // TODO: check apiScope + const data = await queueDocumentDeletion({ + namespaceId: context.namespace.id, + organizationId: context.namespace.organizationId, + documentId: input.documentId, + tenantId: context.tenantId, + }); + + return { + success: true as const, + data: { + ...data, + id: prefixId(data.id, "doc_"), + ingestJobId: prefixId(data.ingestJobId, "job_"), + }, + }; + }); + +const getFileDownloadUrl = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/documents/{documentId}/file-download-url", + operationId: "getFileDownloadUrl", + summary: "Get file download URL", + description: + "Get a presigned download URL for a document's source file. Only available for documents with source type MANAGED_FILE.", + tags: ["Documents"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "getFileDownloadUrl", + security: [{ token: [] }], + responses: { + ...current.responses, + "200": { + description: "The presigned download URL for the file", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean", const: true }, + data: { + type: "object", + properties: { + url: { type: "string" }, + }, + required: ["url"], + additionalProperties: false, + }, + }, + required: ["success", "data"], + additionalProperties: false, + }, + }, + }, + }, + }, + ...makeCodeSamples(ts` +const { url } = await ns.documents.getFileDownloadUrl("doc_123"); +const file = await fetch(url); +fs.writeFileSync("file.pdf", Buffer.from(await file.arrayBuffer())); +`), + }), + }) + .input( + z.object({ + namespaceId: namespaceIdPathSchema, + documentId: documentIdPathSchema, + }), + ) + .use(requireNamespace, (input) => input.namespaceId) + .output(type<{ success: true; data: { url: string } }>()) + .handler(async ({ context, input }) => { + const data = await getDocumentFileDownloadUrl({ + namespaceId: context.namespace.id, + documentId: input.documentId, + tenantId: context.tenantId, + }); + + return { success: true as const, data }; + }); + +const getChunksDownloadUrl = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/documents/{documentId}/chunks-download-url", + operationId: "getChunksDownloadUrl", + summary: "Get chunks download URL", + description: + "Get a presigned download URL for a document's chunks. Only available for completed documents.", + tags: ["Documents"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "getChunksDownloadUrl", + security: [{ token: [] }], + responses: { + ...current.responses, + "200": { + description: "The presigned download URL for the chunks", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean", const: true }, + data: { + type: "object", + properties: { + url: { type: "string" }, + }, + required: ["url"], + additionalProperties: false, + }, + }, + required: ["success", "data"], + additionalProperties: false, + }, + }, + }, + }, + }, + ...makeCodeSamples(ts` +const { url } = await ns.documents.getChunksDownloadUrl("doc_123"); +const data = await (await fetch(url)).json(); +console.log(data); +`), + }), + }) + .input( + z.object({ + namespaceId: namespaceIdPathSchema, + documentId: documentIdPathSchema, + }), + ) + .use(requireNamespace, (input) => input.namespaceId) + .output(type<{ success: true; data: { url: string } }>()) + .handler(async ({ context, input }) => { + const data = await getDocumentChunksDownloadUrl({ + namespaceId: context.namespace.id, + documentId: input.documentId, + tenantId: context.tenantId, + }); + + return { success: true as const, data }; + }); + +export const documentsRouter = { + list, + get, + delete: del, + getFileDownloadUrl, + getChunksDownloadUrl, +}; diff --git a/apps/web/src/server/orpc/public/hosting.ts b/apps/web/src/server/orpc/public/hosting.ts index 8c4e580b..188e8a9b 100644 --- a/apps/web/src/server/orpc/public/hosting.ts +++ b/apps/web/src/server/orpc/public/hosting.ts @@ -1,2 +1,328 @@ -// Stub — replaced by the oRPC migration fan-out. -export const hostingRouter = {}; +import { AgentsetApiError } from "@/lib/api/errors"; +import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { + addDomainSchema, + DomainSchema, + DomainStatusSchema, + HostingSchema, + updateHostingSchema, +} from "@/schemas/api/hosting"; +import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { addDomain as addDomainService } from "@/services/domains/add"; +import { checkDomainStatus as checkDomainStatusService } from "@/services/domains/check-status"; +import { removeDomain as removeDomainService } from "@/services/domains/remove"; +import { deleteHosting } from "@/services/hosting/delete"; +import { enableHosting } from "@/services/hosting/enable"; +import { getHosting } from "@/services/hosting/get"; +import { updateHosting } from "@/services/hosting/update"; +import { z } from "zod/v4"; + +import { prefixId } from "@agentset/utils"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const get = publicApi + .route({ + method: "GET", + path: "/namespace/{namespaceId}/hosting", + operationId: "getHosting", + summary: "Retrieve hosting configuration", + description: "Retrieve the hosting configuration for a namespace.", + tags: ["Hosting"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "get", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const hosting = await ns.hosting.get(); +console.log(hosting); +`, + ), + }), + }) + .input(z.object({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(HostingSchema)) + .handler(async ({ context }) => { + const hosting = await getHosting({ namespaceId: context.namespace.id }); + + if (!hosting) { + throw new AgentsetApiError({ + code: "not_found", + message: "Hosting not found for this namespace.", + }); + } + + return { + success: true as const, + data: { + ...hosting, + namespaceId: prefixId(hosting.namespaceId, "ns_"), + }, + }; + }); + +const enable = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/hosting", + successStatus: 201, + operationId: "enableHosting", + summary: "Enable hosting", + description: "Enable hosting for a namespace.", + tags: ["Hosting"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "enable", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const hosting = await ns.hosting.enable(); +console.log(hosting); +`, + ), + }), + }) + .input(z.object({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(HostingSchema)) + .handler(async ({ context }) => { + const hosting = await enableHosting({ namespaceId: context.namespace.id }); + + return { + success: true as const, + data: { + ...hosting, + namespaceId: prefixId(hosting.namespaceId, "ns_"), + }, + }; + }); + +const updateHandler = publicApi + .input(updateHostingSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(HostingSchema)) + .handler(async ({ context, input }) => { + // input carries the namespaceId path param too; updateHosting only reads + // the updateHostingSchema body fields. + const updatedHosting = await updateHosting({ + namespaceId: context.namespace.id, + input, + }); + + return { + success: true as const, + data: { + ...updatedHosting, + namespaceId: prefixId(updatedHosting.namespaceId, "ns_"), + }, + }; + }); + +const update = updateHandler.route({ + method: "PATCH", + path: "/namespace/{namespaceId}/hosting", + operationId: "updateHosting", + summary: "Update hosting configuration", + description: + "Update the hosting configuration for a namespace. If there is no change, return it as it is.", + tags: ["Hosting"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "update", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const updatedHosting = await ns.hosting.update({ + title: "My Knowledge Base", + welcomeMessage: "Welcome to my knowledge base!", + searchEnabled: true, +}); +console.log(updatedHosting); +`, + ), + }), +}); + +// legacy wire-compat alias, hidden from the generated spec +const updatePut = updateHandler.route({ + method: "PUT", + path: "/namespace/{namespaceId}/hosting", + tags: ["internal-alias"], +}); + +const del = publicApi + .route({ + method: "DELETE", + path: "/namespace/{namespaceId}/hosting", + successStatus: 204, + operationId: "deleteHosting", + summary: "Delete hosting configuration", + description: + "Delete the hosting configuration for a namespace. Also removes the attached custom domain, if any.", + tags: ["Hosting"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "delete", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +await ns.hosting.delete(); +console.log("Hosting deleted"); +`, + ), + }), + }) + .input(z.object({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(z.void()) + .handler(async ({ context }) => { + await deleteHosting({ namespaceId: context.namespace.id }); + }); + +const getHostingOrThrow = async (namespaceId: string) => { + const hosting = await getHosting({ namespaceId }); + + if (!hosting) { + throw new AgentsetApiError({ + code: "not_found", + message: "Hosting is not enabled for this namespace.", + }); + } + + return hosting; +}; + +const checkDomainStatus = publicApi + .route({ + method: "GET", + path: "/namespace/{namespaceId}/hosting/domain", + operationId: "checkDomainStatus", + summary: "Retrieve custom domain status", + description: + "Retrieve the DNS configuration status of the custom domain attached to a namespace's hosting. If the domain is pending verification, a verification attempt is made automatically.", + tags: ["Hosting"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "checkDomainStatus", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const status = await ns.hosting.checkDomainStatus(); +console.log(status); +`, + ), + }), + }) + .input(z.object({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(DomainStatusSchema)) + .handler(async ({ context }) => { + const hosting = await getHostingOrThrow(context.namespace.id); + + if (!hosting.domain) { + throw new AgentsetApiError({ + code: "not_found", + message: "No custom domain is set for this namespace.", + }); + } + + const { status, response } = await checkDomainStatusService({ + hostingId: hosting.id, + }); + + return { + success: true as const, + // the Vercel responses omit fields when the domain is not found, + // the schema defaults fill those in + data: { + domain: hosting.domain.slug, + status, + verified: status === "Valid Configuration", + apexName: response.domainJson.apexName, + verification: response.domainJson.verification, + conflicts: response.configJson.conflicts, + misconfigured: response.configJson.misconfigured, + }, + }; + }); + +const addDomain = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/hosting/domain", + successStatus: 201, + operationId: "addDomain", + summary: "Add a custom domain", + description: + "Attach a custom domain to the hosting configuration of a namespace. Only one domain can be attached at a time.", + tags: ["Hosting"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "addDomain", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const domain = await ns.hosting.addDomain({ domain: "docs.example.com" }); +console.log(domain); +`, + ), + }), + }) + .input(addDomainSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(DomainSchema)) + .handler(async ({ context, input }) => { + const hosting = await getHostingOrThrow(context.namespace.id); + + const domain = await addDomainService({ + hostingId: hosting.id, + domain: input.domain, + }); + + return { success: true as const, data: domain }; + }); + +const removeDomain = publicApi + .route({ + method: "DELETE", + path: "/namespace/{namespaceId}/hosting/domain", + successStatus: 204, + operationId: "removeDomain", + summary: "Remove the custom domain", + description: + "Remove the custom domain attached to the hosting configuration of a namespace.", + tags: ["Hosting"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "removeDomain", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +await ns.hosting.removeDomain(); +console.log("Domain removed"); +`, + ), + }), + }) + .input(z.object({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(z.void()) + .handler(async ({ context }) => { + const hosting = await getHostingOrThrow(context.namespace.id); + await removeDomainService({ hostingId: hosting.id }); + }); + +export const hostingRouter = { + get, + enable, + update, + updatePut, + delete: del, + checkDomainStatus, + addDomain, + removeDomain, +}; diff --git a/apps/web/src/server/orpc/public/ingest-jobs.ts b/apps/web/src/server/orpc/public/ingest-jobs.ts index a308ca73..ce224f44 100644 --- a/apps/web/src/server/orpc/public/ingest-jobs.ts +++ b/apps/web/src/server/orpc/public/ingest-jobs.ts @@ -1,2 +1,344 @@ -// Stub — replaced by the oRPC migration fan-out. -export const ingestJobsRouter = {}; +import { AgentsetApiError } from "@/lib/api/errors"; +import { jobIdPathSchema, namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { + createIngestJobSchema, + getIngestionJobsSchema, + IngestJobSchema, +} from "@/schemas/api/ingest-job"; +import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { createIngestJob } from "@/services/ingest-jobs/create"; +import { deleteIngestJob } from "@/services/ingest-jobs/delete"; +import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; +import { getPaginationArgs, paginateResults } from "@/services/pagination"; +import { z } from "zod/v4"; + +import { db } from "@agentset/db/client"; +import { isFreePlan } from "@agentset/stripe/plans"; +import { normalizeId, prefixId } from "@agentset/utils"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const list = publicApi + .route({ + method: "GET", + path: "/namespace/{namespaceId}/ingest-jobs", + operationId: "listIngestJobs", + summary: "Retrieve a list of ingest jobs", + description: + "Retrieve a paginated list of ingest jobs for the authenticated organization.", + tags: ["Ingest Jobs"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "list", + "x-speakeasy-group": "ingestJobs", + "x-speakeasy-pagination": { + type: "cursor", + inputs: [ + { + name: "cursor", + in: "parameters", + type: "cursor", + }, + ], + outputs: { + nextCursor: "$.pagination.nextCursor", + }, + }, + security: [{ token: [] }], + ...makeCodeSamples(ts` +const jobs = await ns.ingestion.all(); +console.log(jobs); +`), + }), + }) + .input(getIngestionJobsSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(z.array(IngestJobSchema), { hasPagination: true })) + .handler(async ({ context, input }) => { + const { where, ...paginationArgs } = getPaginationArgs( + input, + { + orderBy: input.orderBy, + order: input.order, + }, + "job_", + ); + + const ingestJobs = await db.ingestJob.findMany({ + where: { + namespaceId: context.namespace.id, + tenantId: context.tenantId, + ...(input.statuses && + input.statuses.length > 0 && { + status: { in: input.statuses }, + }), + ...where, + }, + ...paginationArgs, + }); + + const paginated = paginateResults( + input, + ingestJobs.map((job) => ({ + ...job, + id: prefixId(job.id, "job_"), + namespaceId: prefixId(job.namespaceId, "ns_"), + })), + ); + + return { + success: true as const, + data: paginated.records, + pagination: paginated.pagination, + }; + }); + +const create = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/ingest-jobs", + successStatus: 201, + operationId: "createIngestJob", + summary: "Create an ingest job", + description: + "Create an ingest job for the authenticated organization. You can control how documents are parsed and chunked using the optional `config` object (for example, chunk size, overlap, language, and advanced OCR/LLM options).", + tags: ["Ingest Jobs"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "create", + "x-speakeasy-group": "ingestJobs", + security: [{ token: [] }], + ...makeCodeSamples(ts` +const job = await ns.ingestion.create({ + payload: { + type: "TEXT", + text: "This is some content to ingest into the knowledge base.", + }, + config: { + metadata: { + foo: "bar", + }, + chunkSize: 2048, + }, +}); +console.log(job); +`), + }), + }) + .input(createIngestJobSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(IngestJobSchema)) + .handler(async ({ context, input }) => { + const { namespaceId: _namespaceId, ...body } = input; + + if ( + isFreePlan(context.organization.plan) && + body.config?.mode === "accurate" + ) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Accurate mode requires a pro subscription.", + }); + } + + const job = await createIngestJob({ + data: body, + organization: context.organization, + namespaceId: context.namespace.id, + tenantId: context.tenantId, + }); + + return { + success: true as const, + data: { + ...job, + id: prefixId(job.id, "job_"), + namespaceId: prefixId(job.namespaceId, "ns_"), + }, + }; + }); + +const get = publicApi + .route({ + method: "GET", + path: "/namespace/{namespaceId}/ingest-jobs/{jobId}", + operationId: "getIngestJobInfo", + summary: "Retrieve an ingest job", + description: "Retrieve the info for an ingest job.", + tags: ["Ingest Jobs"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "get", + "x-speakeasy-group": "ingestJobs", + security: [{ token: [] }], + ...makeCodeSamples(ts` +const job = await ns.ingestion.get("job_123"); +console.log(job); +`), + }), + }) + .input( + z.object({ + namespaceId: namespaceIdPathSchema, + jobId: jobIdPathSchema, + }), + ) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(IngestJobSchema)) + .handler(async ({ context, input }) => { + const jobId = normalizeId(input.jobId, "job_"); + if (!jobId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid job id", + }); + } + + // note: deliberately not tenant-filtered (parity with the legacy route) + const data = await db.ingestJob.findUnique({ + where: { + id: jobId, + namespaceId: context.namespace.id, + }, + }); + + if (!data) { + throw new AgentsetApiError({ + code: "not_found", + message: "Ingest job not found", + }); + } + + return { + success: true as const, + data: { + ...data, + id: prefixId(data.id, "job_"), + namespaceId: prefixId(data.namespaceId, "ns_"), + }, + }; + }); + +const del = publicApi + .route({ + method: "DELETE", + path: "/namespace/{namespaceId}/ingest-jobs/{jobId}", + operationId: "deleteIngestJob", + summary: "Delete an ingest job", + description: "Delete an ingest job for the authenticated organization.", + tags: ["Ingest Jobs"], + spec: (current) => { + // The legacy spec documented this soft delete under "204" even though + // the route responds 200 with the queued-for-delete record. Keep the + // published document byte-compatible without changing wire behavior. + const { "200": okResponse, ...restResponses } = current.responses ?? {}; + + return { + ...current, + ...(okResponse && { + responses: { + "204": okResponse, + ...restResponses, + }, + }), + "x-speakeasy-name-override": "delete", + "x-speakeasy-group": "ingestJobs", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples(ts` +await ns.ingestion.delete("job_123"); +console.log("Ingest job queued for deletion"); +`), + }; + }, + }) + .input( + z.object({ + namespaceId: namespaceIdPathSchema, + jobId: jobIdPathSchema, + }), + ) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(IngestJobSchema)) + .handler(async ({ context, input }) => { + const jobId = normalizeId(input.jobId, "job_"); + if (!jobId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid job id", + }); + } + + const data = await deleteIngestJob({ + jobId, + namespaceId: context.namespace.id, + organizationId: context.namespace.organizationId, + }); + + return { + success: true as const, + data: { + ...data, + id: prefixId(data.id, "job_"), + namespaceId: prefixId(data.namespaceId, "ns_"), + }, + }; + }); + +const reIngest = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/ingest-jobs/{jobId}/re-ingest", + operationId: "reIngestJob", + summary: "Re-ingest a job", + description: "Re-ingest a job for the authenticated organization.", + tags: ["Ingest Jobs"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "reIngest", + "x-speakeasy-group": "ingestJobs", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples(ts` +const result = await ns.ingestion.reIngest("job_123"); +console.log("Job queued for re-ingestion: ", result); +`), + }), + }) + .input( + z.object({ + namespaceId: namespaceIdPathSchema, + jobId: jobIdPathSchema, + }), + ) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(IngestJobSchema.pick({ id: true }))) + .handler(async ({ context, input }) => { + const jobId = normalizeId(input.jobId, "job_"); + if (!jobId) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid job id", + }); + } + + const job = await reIngestJob({ + jobId, + namespaceId: context.namespace.id, + organizationId: context.namespace.organizationId, + plan: context.organization.plan, + }); + + return { + success: true as const, + data: { id: prefixId(job.id, "job_") }, + }; + }); + +export const ingestJobsRouter = { + list, + create, + get, + delete: del, + reIngest, +}; diff --git a/apps/web/src/server/orpc/public/namespaces.ts b/apps/web/src/server/orpc/public/namespaces.ts index 57399170..7fccfa06 100644 --- a/apps/web/src/server/orpc/public/namespaces.ts +++ b/apps/web/src/server/orpc/public/namespaces.ts @@ -1,2 +1,237 @@ -// Stub — replaced by the oRPC migration fan-out. -export const namespacesRouter = {}; +import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { + createNamespaceSchema, + NamespaceSchema, + updateNamespaceSchema, +} from "@/schemas/api/namespace"; +import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { createNamespace } from "@/services/namespaces/create"; +import { deleteNamespace } from "@/services/namespaces/delete"; +import { updateNamespace } from "@/services/namespaces/update"; +import { z } from "zod/v4"; + +import { NamespaceStatus } from "@agentset/db"; +import { db } from "@agentset/db/client"; +import { prefixId } from "@agentset/utils"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const list = publicApi + .route({ + method: "GET", + path: "/namespace", + operationId: "listNamespaces", + summary: "Retrieve a list of namespaces", + description: + "Retrieve a list of namespaces for the authenticated organization.", + tags: ["Namespaces"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "list", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const namespaces = await agentset.namespaces.list(); +console.log(namespaces); +`, + { isNs: false }, + ), + }), + }) + .output(successSchema(z.array(NamespaceSchema))) + .handler(async ({ context }) => { + const namespaces = await db.namespace.findMany({ + where: { + organizationId: context.organization.id, + status: NamespaceStatus.ACTIVE, + }, + orderBy: { + createdAt: "asc", + }, + }); + + return { + success: true as const, + data: namespaces.map((namespace) => ({ + ...namespace, + id: prefixId(namespace.id, "ns_"), + organizationId: prefixId(namespace.organizationId, "org_"), + })), + }; + }); + +const create = publicApi + .route({ + method: "POST", + path: "/namespace", + successStatus: 201, + operationId: "createNamespace", + summary: "Create a namespace.", + description: "Create a namespace for the authenticated organization.", + tags: ["Namespaces"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "create", + "x-speakeasy-usage-example": true, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const namespace = await agentset.namespaces.create({ + name: "My Knowledge Base", + slug: "my-knowledge-base", + // embeddingConfig: {...}, + // vectorStoreConfig: {...}, +}); +console.log(namespace); +`, + { isNs: false }, + ), + }), + }) + .input(createNamespaceSchema) + .output(successSchema(NamespaceSchema)) + .handler(async ({ context, input }) => { + // TODO: check apiScope + const namespace = await createNamespace({ + organizationId: context.organization.id, + data: input, + }); + + return { + success: true as const, + data: { + ...namespace, + id: prefixId(namespace.id, "ns_"), + organizationId: prefixId(namespace.organizationId, "org_"), + }, + }; + }); + +const get = publicApi + .route({ + method: "GET", + path: "/namespace/{namespaceId}", + operationId: "getNamespace", + summary: "Retrieve a namespace", + description: "Retrieve the info for a namespace.", + tags: ["Namespaces"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "get", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const namespace = await agentset.namespaces.get("ns_xxx"); +console.log(namespace); +`, + { isNs: false }, + ), + }), + }) + .input(z.object({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(NamespaceSchema)) + .handler(async ({ context }) => { + return { + success: true as const, + data: { + ...context.namespace, + id: prefixId(context.namespace.id, "ns_"), + organizationId: prefixId(context.namespace.organizationId, "org_"), + }, + }; + }); + +const updateHandler = publicApi + .input(updateNamespaceSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(NamespaceSchema)) + .handler(async ({ context, input }) => { + const updatedNamespace = await updateNamespace({ + namespaceId: context.namespace.id, + organizationId: context.namespace.organizationId, + data: { name: input.name, slug: input.slug }, + }); + + return { + success: true as const, + data: { + ...updatedNamespace, + id: prefixId(updatedNamespace.id, "ns_"), + organizationId: prefixId(context.namespace.organizationId, "org_"), + }, + }; + }); + +const update = updateHandler.route({ + method: "PATCH", + path: "/namespace/{namespaceId}", + operationId: "updateNamespace", + summary: "Update a namespace.", + description: + "Update a namespace for the authenticated organization. If there is no change, return it as it is.", + tags: ["Namespaces"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "update", + "x-speakeasy-max-method-params": 2, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const updatedNamespace = await agentset.namespaces.update("ns_xxx", { + name: "Updated Knowledge Base", +}); +console.log(updatedNamespace); +`, + { isNs: false }, + ), + }), +}); + +// legacy wire-compat alias, hidden from the generated spec +const updatePut = updateHandler.route({ + method: "PUT", + path: "/namespace/{namespaceId}", + tags: ["internal-alias"], +}); + +const del = publicApi + .route({ + method: "DELETE", + path: "/namespace/{namespaceId}", + successStatus: 204, + operationId: "deleteNamespace", + summary: "Delete a namespace.", + description: + "Delete a namespace for the authenticated organization. This will delete all the data associated with the namespace.", + tags: ["Namespaces"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "delete", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +await agentset.namespaces.delete("ns_xxx"); +console.log("Namespace queued for deletion"); +`, + { isNs: false }, + ), + }), + }) + .input(z.object({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(z.void()) + .handler(async ({ context }) => { + // TODO: check apiScope + await deleteNamespace({ namespaceId: context.namespace.id }); + }); + +export const namespacesRouter = { + list, + create, + get, + update, + updatePut, + delete: del, +}; diff --git a/apps/web/src/server/orpc/public/search.ts b/apps/web/src/server/orpc/public/search.ts index fabadaef..3b147a32 100644 --- a/apps/web/src/server/orpc/public/search.ts +++ b/apps/web/src/server/orpc/public/search.ts @@ -1,2 +1,88 @@ -// Stub — replaced by the oRPC migration fan-out. -export const searchRouter = {}; +import { + checkSearchLimit, + incrementOrganizationSearchUsage, +} from "@/lib/api/usage"; +import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { NodeSchema } from "@/schemas/api/node"; +import { queryVectorStoreSchema } from "@/schemas/api/query"; +import { + publicApi, + requireNamespace, + successSchema, +} from "@/server/orpc/base"; +import { searchNamespace } from "@/services/search"; +import { toOpenAPISchema } from "@orpc/openapi"; +import { type } from "@orpc/server"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { z } from "zod/v4"; + +import type { QueryVectorStoreResult } from "@agentset/engine"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const execute = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/search", + operationId: "search", + summary: "Search a namespace", + description: "Search a namespace for a query.", + tags: ["Search"], + spec: (current) => { + // The handler returns the raw service results without an output parse + // (parity with the legacy route), so the 200 response is documented + // here from the old spec file instead of via `.output()`. + const [, responseJsonSchema] = new ZodToJsonSchemaConverter().convert( + successSchema(z.array(NodeSchema)), + { strategy: "output" }, + ); + + return { + ...current, + "x-speakeasy-name-override": "execute", + "x-speakeasy-group": "search", + security: [{ token: [] }], + responses: { + "200": { + description: "The retrieved namespace", + content: { + "application/json": { + schema: toOpenAPISchema(responseJsonSchema), + }, + }, + }, + }, + ...makeCodeSamples(ts` +const results = await ns.search("What is machine learning?", { + topK: 20, + rerank: true, + rerankLimit: 10, +}); +console.log(results); +`), + }; + }, + }) + .input(queryVectorStoreSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(type<{ success: true; data: QueryVectorStoreResult["results"] }>()) + .handler(async ({ context, input }) => { + // TODO: set hard limits to prevent abuse + checkSearchLimit(context.organization); + + const { namespaceId: _namespaceId, ...options } = input; + + const results = await searchNamespace({ + namespace: context.namespace, + tenantId: context.tenantId, + options, + }); + + incrementOrganizationSearchUsage(context.organization.id, 1); + + return { success: true as const, data: results }; + }); + +export const searchRouter = { + execute, +}; diff --git a/apps/web/src/server/orpc/public/uploads.ts b/apps/web/src/server/orpc/public/uploads.ts index 810352d9..42511003 100644 --- a/apps/web/src/server/orpc/public/uploads.ts +++ b/apps/web/src/server/orpc/public/uploads.ts @@ -1,2 +1,159 @@ -// Stub — replaced by the oRPC migration fan-out. -export const uploadsRouter = {}; +import { AgentsetApiError } from "@/lib/api/errors"; +import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { + batchUploadSchema, + uploadFileSchema, + UploadResultSchema, +} from "@/schemas/api/upload"; +import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { createBatchUpload, createUpload } from "@/services/uploads"; +import { z } from "zod/v4"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const create = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/uploads", + successStatus: 201, + operationId: "createUpload", + summary: "Create presigned URL for file upload", + description: + "Generate a presigned URL for uploading a single file to the specified namespace.", + tags: ["Uploads"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "create", + security: [{ token: [] }], + ...makeCodeSamples(ts` +const result = await ns.uploads.upload({ + file: fs.createReadStream("./example.md"), + contentType: "text/markdown", +}); +console.log("Uploaded successfully: ", result.key); + +// OR get the pre-signed URL manually +const file = fs.readFileSync("./example.md"); +const result = await ns.uploads.create({ + fileName: "example.md", + fileSize: file.length, + contentType: "text/markdown", +}); + +await fetch(result.url, { + method: "PUT", + body: file, + headers: { + "Content-Type": "text/markdown", + }, +}); +console.log("Uploaded successfully: ", result.key); +`), + }), + }) + .input(uploadFileSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(UploadResultSchema)) + .handler(async ({ context, input }) => { + const result = await createUpload({ + namespaceId: context.namespace.id, + file: { + fileName: input.fileName, + contentType: input.contentType, + fileSize: input.fileSize, + }, + }); + + if (!result.success) { + throw new AgentsetApiError({ + code: "internal_server_error", + message: result.error, + }); + } + + return { success: true as const, data: result.data }; + }); + +const createBatch = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/uploads/batch", + successStatus: 201, + operationId: "createBatchUpload", + summary: "Create presigned URLs for batch file upload", + description: + "Generate presigned URLs for uploading multiple files to the specified namespace.", + tags: ["Uploads"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "createBatch", + security: [{ token: [] }], + ...makeCodeSamples(ts` +const results = await ns.uploads.uploadBatch([ + { + file: fs.createReadStream("./example-1.md"), + contentType: "text/markdown", + }, + { + file: fs.createReadStream("./example-2.md"), + contentType: "text/markdown", + }, +]); +console.log("Uploaded successfully: ", results.map((result) => result.key)); + +// OR get the pre-signed URLs manually +const file1 = fs.readFileSync("./example-1.md"); +const file2 = fs.readFileSync("./example-2.md"); + +const results = await ns.uploads.createBatch({ + files: [ + { + fileName: "example-1.md", + fileSize: file1.length, + contentType: "text/markdown", + }, + { + fileName: "example-2.md", + fileSize: file2.length, + contentType: "text/markdown", + }, + ], +}); + +await Promise.all([file1, file2].map(async (file, i) => { + await fetch(results[i]!.url, { + method: "PUT", + body: file, + headers: { + "Content-Type": "text/markdown", + }, + }); +})); + +console.log("Upload URLs:", results.map((result) => result.key)); +`), + }), + }) + .input(batchUploadSchema.extend({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(successSchema(z.array(UploadResultSchema))) + .handler(async ({ context, input }) => { + const result = await createBatchUpload({ + namespaceId: context.namespace.id, + files: input.files, + }); + + if (!result.success) { + throw new AgentsetApiError({ + code: "internal_server_error", + message: result.error, + }); + } + + return { success: true as const, data: result.data }; + }); + +export const uploadsRouter = { + create, + createBatch, +}; diff --git a/apps/web/src/server/orpc/public/warm-up.ts b/apps/web/src/server/orpc/public/warm-up.ts index 00f804be..ca1a864f 100644 --- a/apps/web/src/server/orpc/public/warm-up.ts +++ b/apps/web/src/server/orpc/public/warm-up.ts @@ -1,2 +1,86 @@ -// Stub — replaced by the oRPC migration fan-out. -export const warmUpRouter = {}; +import { AgentsetApiError } from "@/lib/api/errors"; +import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { publicApi, requireNamespace } from "@/server/orpc/base"; +import { type } from "@orpc/server"; +import { z } from "zod/v4"; + +import { getNamespaceVectorStore } from "@agentset/engine"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const warmUp = publicApi + .route({ + method: "POST", + path: "/namespace/{namespaceId}/warm-up", + operationId: "warmUp", + summary: "Warm cache for a namespace", + description: + "Pre-loads the namespace into the vector store's cache for faster query performance. Not all vector stores support this operation. Currently only Turbopuffer supports this operation.", + tags: ["Namespaces"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "warmUp", + "x-speakeasy-group": "namespace", + security: [{ token: [] }], + // the handler doesn't parse its output (typed passthrough), so the + // response documentation is carried here (copied from openapi/v1/warm-up.ts) + responses: { + ...current.responses, + "200": { + description: "Cache warming started", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + const: true, + }, + data: { + type: "object", + properties: { + status: { + type: "boolean", + }, + }, + required: ["status"], + additionalProperties: false, + }, + }, + required: ["success", "data"], + additionalProperties: false, + }, + }, + }, + }, + }, + ...makeCodeSamples(ts` +await ns.warmUp(); +console.log("Cache warmed successfully"); +`), + }), + }) + .input(z.object({ namespaceId: namespaceIdPathSchema })) + .use(requireNamespace, (input) => input.namespaceId) + .output(type<{ success: true; data: { status: boolean } }>()) + .handler(async ({ context }) => { + const vectorStore = await getNamespaceVectorStore( + context.namespace, + context.tenantId, + ); + const result = await vectorStore.warmCache(); + + if (result === "UNSUPPORTED") { + throw new AgentsetApiError({ + code: "bad_request", + message: "Warm cache is not supported for this vector store", + }); + } + + return { success: true as const, data: { status: true } }; + }); + +export const warmUpRouter = { + warmUp, +}; diff --git a/apps/web/src/server/orpc/public/webhooks.ts b/apps/web/src/server/orpc/public/webhooks.ts index 7062b1a5..61d15bba 100644 --- a/apps/web/src/server/orpc/public/webhooks.ts +++ b/apps/web/src/server/orpc/public/webhooks.ts @@ -1,2 +1,319 @@ -// Stub — replaced by the oRPC migration fan-out. -export const webhooksRouter = {}; +import { AgentsetApiError } from "@/lib/api/errors"; +import { + createWebhookSchema, + updateWebhookSchema, + WebhookDetailsSchema, + WebhookSchema, + WebhookSummarySchema, +} from "@/schemas/api/webhook"; +import { publicApi, successSchema } from "@/server/orpc/base"; +import { createWebhook } from "@/services/webhooks/create"; +import { deleteWebhook } from "@/services/webhooks/delete"; +import { getWebhook } from "@/services/webhooks/get"; +import { listWebhooks } from "@/services/webhooks/list"; +import { requireWebhooksPlan } from "@/services/webhooks/plan"; +import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; +import { applyWebhookUpdate } from "@/services/webhooks/update"; +import { z } from "zod/v4"; + +import type { WebhookProps } from "@agentset/webhooks"; +import { normalizeId, prefixId } from "@agentset/utils"; + +import { makeCodeSamples, ts } from "./code-samples"; + +const webhookIdPathSchema = z.string().meta({ + examples: ["wh_123"], + description: "The id of the webhook (prefixed with wh_)", + param: { + in: "path", + name: "webhookId", + id: "WebhookIdRef", + }, +}); + +const webhookNotFoundError = () => + new AgentsetApiError({ + code: "not_found", + message: "Webhook not found.", + }); + +const prefixNamespaceIds = (webhook: T) => ({ + ...webhook, + namespaceIds: (webhook.namespaceIds ?? []).map((id) => prefixId(id, "ns_")), +}); + +// The legacy route parsed the body alone, so the "at least one field" refine +// on `updateWebhookSchema` could fire; with the `webhookId` path param merged +// into the input it is always satisfied, so re-apply it ignoring `webhookId` +// to keep the empty-body 422 (instead of the service's 400 fallback). +const updateWebhookInputSchema = updateWebhookSchema + .extend({ webhookId: webhookIdPathSchema }) + .refine( + (body) => + Object.keys(body).some( + (key) => + key !== "webhookId" && body[key as keyof typeof body] !== undefined, + ), + { + message: "At least one field must be provided.", + }, + ); + +const list = publicApi + .route({ + method: "GET", + path: "/webhooks", + operationId: "listWebhooks", + summary: "Retrieve a list of webhooks", + description: + "Retrieve a list of webhooks for the authenticated organization. The signing secret is not included, retrieve a single webhook to get it.", + tags: ["Webhooks"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "list", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const webhooks = await agentset.webhooks.list(); +console.log(webhooks); +`, + { isNs: false }, + ), + }), + }) + .output(successSchema(z.array(WebhookSummarySchema))) + .handler(async ({ context }) => { + const webhooks = await listWebhooks({ + organizationId: context.organization.id, + }); + + return { + success: true as const, + data: webhooks.map(prefixNamespaceIds), + }; + }); + +const create = publicApi + .route({ + method: "POST", + path: "/webhooks", + successStatus: 201, + operationId: "createWebhook", + summary: "Create a webhook.", + description: + "Create a webhook for the authenticated organization. Requires the Pro plan or above.", + tags: ["Webhooks"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "create", + "x-speakeasy-usage-example": true, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const webhook = await agentset.webhooks.create({ + name: "My Webhook", + url: "https://example.com/webhooks/agentset", + triggers: ["document.ready", "ingest_job.ready"], + // namespaceIds: ["ns_xxx"], +}); +console.log(webhook); +`, + { isNs: false }, + ), + }), + }) + .input(createWebhookSchema) + .output(successSchema(WebhookSchema)) + .handler(async ({ context, input }) => { + requireWebhooksPlan(context.organization.plan); + + const webhook = await createWebhook({ + ...input, + namespaceIds: input.namespaceIds?.map((id) => normalizeId(id, "ns_")), + organizationId: context.organization.id, + }); + + return { + success: true as const, + data: prefixNamespaceIds(webhook), + }; + }); + +const get = publicApi + .route({ + method: "GET", + path: "/webhooks/{webhookId}", + operationId: "getWebhook", + summary: "Retrieve a webhook", + description: + "Retrieve the info for a webhook, including its signing secret and delivery failure stats.", + tags: ["Webhooks"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "get", + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const webhook = await agentset.webhooks.get("wh_xxx"); +console.log(webhook); +`, + { isNs: false }, + ), + }), + }) + .input(z.object({ webhookId: webhookIdPathSchema })) + .output(successSchema(WebhookDetailsSchema)) + .handler(async ({ context, input }) => { + const webhook = await getWebhook({ + organizationId: context.organization.id, + webhookId: input.webhookId, + }); + + if (!webhook) { + throw webhookNotFoundError(); + } + + return { + success: true as const, + data: prefixNamespaceIds(webhook), + }; + }); + +const updateHandler = publicApi + .input(updateWebhookInputSchema) + .output(successSchema(WebhookSchema)) + .handler(async ({ context, input }) => { + const { webhookId, ...body } = input; + + const webhook = await applyWebhookUpdate({ + organizationId: context.organization.id, + plan: context.organization.plan, + webhookId, + ...body, + }); + + return { + success: true as const, + data: prefixNamespaceIds(webhook), + }; + }); + +const update = updateHandler.route({ + method: "PATCH", + path: "/webhooks/{webhookId}", + operationId: "updateWebhook", + summary: "Update a webhook.", + description: + "Update a webhook for the authenticated organization. Field updates require the Pro plan or above; enabling/disabling a webhook does not.", + tags: ["Webhooks"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "update", + "x-speakeasy-max-method-params": 2, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const updatedWebhook = await agentset.webhooks.update("wh_xxx", { + name: "Updated Webhook", + // enabled: false, +}); +console.log(updatedWebhook); +`, + { isNs: false }, + ), + }), +}); + +// legacy wire-compat alias, hidden from the generated spec +const updatePut = updateHandler.route({ + method: "PUT", + path: "/webhooks/{webhookId}", + tags: ["internal-alias"], +}); + +const del = publicApi + .route({ + method: "DELETE", + path: "/webhooks/{webhookId}", + successStatus: 204, + operationId: "deleteWebhook", + summary: "Delete a webhook.", + description: "Delete a webhook for the authenticated organization.", + tags: ["Webhooks"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "delete", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +await agentset.webhooks.delete("wh_xxx"); +console.log("Webhook deleted"); +`, + { isNs: false }, + ), + }), + }) + .input(z.object({ webhookId: webhookIdPathSchema })) + .output(z.void()) + .handler(async ({ context, input }) => { + const webhook = await deleteWebhook({ + organizationId: context.organization.id, + webhookId: input.webhookId, + }); + + if (!webhook) { + throw webhookNotFoundError(); + } + }); + +const regenerateSecret = publicApi + .route({ + method: "POST", + path: "/webhooks/{webhookId}/regenerate-secret", + operationId: "regenerateWebhookSecret", + summary: "Regenerate a webhook secret.", + description: + "Generate a new signing secret for a webhook. The old secret stops being used immediately.", + tags: ["Webhooks"], + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "regenerateSecret", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples( + ts` +const webhook = await agentset.webhooks.regenerateSecret("wh_xxx"); +console.log(webhook.secret); +`, + { isNs: false }, + ), + }), + }) + .input(z.object({ webhookId: webhookIdPathSchema })) + .output(successSchema(WebhookSchema)) + .handler(async ({ context, input }) => { + const webhook = await regenerateWebhookSecret({ + organizationId: context.organization.id, + webhookId: input.webhookId, + }); + + if (!webhook) { + throw webhookNotFoundError(); + } + + return { + success: true as const, + data: prefixNamespaceIds(webhook), + }; + }); + +export const webhooksRouter = { + list, + create, + get, + update, + updatePut, + delete: del, + regenerateSecret, +}; From 1e17cb52ad31f60dfaec818af96f113746bde4b4 Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Sat, 4 Jul 2026 11:32:10 +0200 Subject: [PATCH 05/10] Migrate the dashboard to the oRPC TanStack Query client and remove tRPC All 48 consumer files swap useTRPC()/trpcClient for the module-level orpc utils and imperative client (query keys, partial-match invalidations, optimistic setQueryData writes, isMutating filters, and RouterOutputs types all derived from the oRPC utils). providers.tsx now mounts a plain QueryClientProvider; superjson and the @trpc/* dependencies are gone. --- apps/web/package.json | 4 - .../api/(internal-api)/trpc/[trpc]/route.ts | 33 -- .../[namespaceSlug]/documents/actions.tsx | 25 +- .../documents/chunks-drawer.tsx | 12 +- .../documents/config-modal.tsx | 11 +- .../documents/document-actions.tsx | 16 +- .../documents/ingest-modal/index.tsx | 18 +- .../documents/ingest-modal/use-ingest.ts | 5 +- .../documents/use-documents.ts | 17 +- .../[namespaceSlug]/documents/use-jobs.ts | 17 +- .../documents/use-pending-jobs.ts | 17 +- .../hosting/domain-card/index.tsx | 27 +- .../[namespaceSlug]/hosting/empty-state.tsx | 9 +- .../[namespaceSlug]/hosting/page.client.tsx | 7 +- .../hosting/use-hosting-form.ts | 17 +- .../[namespaceSlug]/playground/api-dialog.tsx | 11 +- .../playground/search/page.client.tsx | 17 +- .../quick-start/page.client.tsx | 15 +- .../danger/delete-namespace-button.tsx | 9 +- .../_components/payment-methods/index.tsx | 13 +- .../billing/_components/usage/index.tsx | 11 +- .../_components/usage/subscription-menu.tsx | 7 +- .../[slug]/billing/invoices/page.client.tsx | 13 +- .../[slug]/billing/upgrade/upgrade-button.tsx | 5 +- .../[slug]/namespaces-empty-state.tsx | 9 +- .../(dashboard)/[slug]/page.client.tsx | 7 +- .../[slug]/settings/api-keys/actions.tsx | 15 +- .../settings/api-keys/create-api-key.tsx | 15 +- .../[slug]/settings/api-keys/page.client.tsx | 7 +- .../settings/danger/delete-org-button.tsx | 5 +- .../[slug]/settings/page.client.tsx | 12 +- .../(dashboard)/[slug]/team/invite-dialog.tsx | 16 +- .../(dashboard)/[slug]/team/member-card.tsx | 12 +- .../(dashboard)/[slug]/team/page.client.tsx | 7 +- .../(dashboard)/[slug]/team/remove-member.tsx | 16 +- .../[slug]/team/revoke-invitation.tsx | 16 +- .../[slug]/webhooks/[webhookId]/edit/page.tsx | 11 +- apps/web/src/app/providers.tsx | 7 +- .../app-sidebar/org-switcher/index.tsx | 7 +- .../create-namespace/details-step.tsx | 4 +- .../src/components/create-namespace/index.tsx | 9 +- .../namespace-switcher.tsx | 7 +- .../webhooks/add-edit-webhook-form.tsx | 31 +- .../webhooks/send-test-webhook-modal.tsx | 5 +- .../components/webhooks/webhook-events.tsx | 11 +- .../components/webhooks/webhook-header.tsx | 31 +- apps/web/src/hooks/use-namespace.ts | 19 +- apps/web/src/hooks/use-organization.ts | 19 +- apps/web/src/hooks/use-upload.ts | 5 +- apps/web/src/hooks/use-webhooks.ts | 16 +- apps/web/src/lib/query-client.ts | 22 ++ apps/web/src/server/api/auth.ts | 25 -- apps/web/src/server/api/root.ts | 31 -- apps/web/src/server/api/routers/api-keys.ts | 111 ------ apps/web/src/server/api/routers/billing.ts | 374 ------------------ apps/web/src/server/api/routers/documents.ts | 193 --------- apps/web/src/server/api/routers/domains.ts | 67 ---- apps/web/src/server/api/routers/hosting.ts | 70 ---- .../web/src/server/api/routers/ingest-jobs.ts | 152 ------- apps/web/src/server/api/routers/namespaces.ts | 278 ------------- .../src/server/api/routers/organizations.ts | 132 ------- apps/web/src/server/api/routers/search.ts | 60 --- apps/web/src/server/api/routers/uploads.ts | 74 ---- apps/web/src/server/api/routers/webhooks.ts | 350 ---------------- apps/web/src/server/api/trpc.ts | 183 --------- apps/web/src/server/orpc/public/chat.ts | 25 +- apps/web/src/trpc/query-client.ts | 33 -- apps/web/src/trpc/react.tsx | 74 ---- apps/web/src/trpc/server.tsx | 55 --- bun.lock | 84 ++-- 70 files changed, 396 insertions(+), 2652 deletions(-) delete mode 100644 apps/web/src/app/api/(internal-api)/trpc/[trpc]/route.ts create mode 100644 apps/web/src/lib/query-client.ts delete mode 100644 apps/web/src/server/api/auth.ts delete mode 100644 apps/web/src/server/api/root.ts delete mode 100644 apps/web/src/server/api/routers/api-keys.ts delete mode 100644 apps/web/src/server/api/routers/billing.ts delete mode 100644 apps/web/src/server/api/routers/documents.ts delete mode 100644 apps/web/src/server/api/routers/domains.ts delete mode 100644 apps/web/src/server/api/routers/hosting.ts delete mode 100644 apps/web/src/server/api/routers/ingest-jobs.ts delete mode 100644 apps/web/src/server/api/routers/namespaces.ts delete mode 100644 apps/web/src/server/api/routers/organizations.ts delete mode 100644 apps/web/src/server/api/routers/search.ts delete mode 100644 apps/web/src/server/api/routers/uploads.ts delete mode 100644 apps/web/src/server/api/routers/webhooks.ts delete mode 100644 apps/web/src/server/api/trpc.ts delete mode 100644 apps/web/src/trpc/query-client.ts delete mode 100644 apps/web/src/trpc/react.tsx delete mode 100644 apps/web/src/trpc/server.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 7c472ac1..c7936437 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,9 +44,6 @@ "@orpc/zod": "1.14.6", "@t3-oss/env-nextjs": "catalog:", "@tanstack/react-query": "catalog:", - "@trpc/client": "catalog:", - "@trpc/server": "catalog:", - "@trpc/tanstack-react-query": "catalog:", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.34.9", "@vercel/functions": "^3.3.6", @@ -67,7 +64,6 @@ "resend": "^6.7.0", "server-only": "^0.0.1", "shadcn": "^3.8.4", - "superjson": "^2.2.6", "tw-animate-css": "^1.4.0", "ua-parser-js": "^2.0.8", "usehooks-ts": "^3.1.1", diff --git a/apps/web/src/app/api/(internal-api)/trpc/[trpc]/route.ts b/apps/web/src/app/api/(internal-api)/trpc/[trpc]/route.ts deleted file mode 100644 index 528a0738..00000000 --- a/apps/web/src/app/api/(internal-api)/trpc/[trpc]/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { NextRequest } from "next/server"; -import { env } from "@/env"; -import { appRouter } from "@/server/api/root"; -import { createTRPCContext } from "@/server/api/trpc"; -import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; - -/** - * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when - * handling a HTTP request (e.g. when you make requests from Client Components). - */ -const createContext = async (req: NextRequest) => { - return createTRPCContext({ - headers: req.headers, - }); -}; - -const handler = (req: NextRequest) => - fetchRequestHandler({ - endpoint: "/api/trpc", - req, - router: appRouter, - createContext: () => createContext(req), - onError: - env.NODE_ENV === "development" - ? ({ path, error }) => { - console.error( - `❌ tRPC failed on ${path ?? ""}: ${error.message}`, - ); - } - : undefined, - }); - -export { handler as GET, handler as POST }; diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/actions.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/actions.tsx index 28cf77b0..588ec0a6 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/actions.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/actions.tsx @@ -2,7 +2,7 @@ import type { Row } from "@tanstack/react-table"; import { useState } from "react"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation"; import { useNamespace } from "@/hooks/use-namespace"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CopyIcon, @@ -27,19 +27,19 @@ import type { JobCol } from "./columns"; export function JobActions({ row }: { row: Row }) { const queryClient = useQueryClient(); const namespace = useNamespace(); - const trpc = useTRPC(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { mutate: deleteJob, isPending: isDeletePending } = useMutation( - trpc.ingestJob.delete.mutationOptions({ + orpc.ingestJob.delete.mutationOptions({ onSuccess: () => { toast.success("Job queued for deletion"); setDeleteDialogOpen(false); - void queryClient.invalidateQueries( - trpc.ingestJob.all.queryFilter({ - namespaceId: namespace.id, + void queryClient.invalidateQueries({ + queryKey: orpc.ingestJob.all.key({ + input: { namespaceId: namespace.id }, + type: "query", }), - ); + }); }, onError: (error) => { toast.error(error.message); @@ -48,14 +48,15 @@ export function JobActions({ row }: { row: Row }) { ); const { mutate: reIngestJob, isPending: isReIngestPending } = useMutation( - trpc.ingestJob.reIngest.mutationOptions({ + orpc.ingestJob.reIngest.mutationOptions({ onSuccess: () => { toast.success("Job re-ingestion started"); - void queryClient.invalidateQueries( - trpc.ingestJob.all.queryFilter({ - namespaceId: namespace.id, + void queryClient.invalidateQueries({ + queryKey: orpc.ingestJob.all.key({ + input: { namespaceId: namespace.id }, + type: "query", }), - ); + }); }, onError: (error) => { toast.error(error.message); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/chunks-drawer.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/chunks-drawer.tsx index 60553bd5..33f01dbe 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/chunks-drawer.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/chunks-drawer.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; -import { trpcClient } from "@/trpc/react"; +import { client } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { ChevronDownIcon, CopyIcon, SearchIcon } from "lucide-react"; import { toast } from "sonner"; @@ -43,12 +43,10 @@ export function ChunksDrawer({ queryKey: ["document-chunks", namespace.id, documentId], queryFn: async () => { // First fetch the presigned URL - const urlResponse = await trpcClient.document.getChunksDownloadUrl.mutate( - { - documentId, - namespaceId: namespace.id, - }, - ); + const urlResponse = await client.document.getChunksDownloadUrl({ + documentId, + namespaceId: namespace.id, + }); if (!urlResponse?.url) { throw new Error("Could not get chunks URL"); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/config-modal.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/config-modal.tsx index 6325bd23..8d5a597c 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/config-modal.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/config-modal.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { SingleLanguageCodeBlock } from "@agentset/ui/ai/code-block"; @@ -16,16 +16,15 @@ import { Skeleton } from "@agentset/ui/skeleton"; export function ConfigModal({ jobId }: { jobId: string }) { const [open, setOpen] = useState(false); - const trpc = useTRPC(); const namespace = useNamespace(); const { data: config, isLoading } = useQuery({ - ...trpc.ingestJob.getConfig.queryOptions( - { + ...orpc.ingestJob.getConfig.queryOptions({ + input: { jobId, namespaceId: namespace.id, }, - { enabled: open }, - ), + enabled: open, + }), }); const configStr = useMemo(() => { diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx index 526ce5ef..cea259ae 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation"; import { useNamespace } from "@/hooks/use-namespace"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CopyIcon, @@ -28,12 +28,11 @@ import type { DocumentCol } from "./documents-columns"; export default function DocumentActions({ row }: { row: Row }) { const namespace = useNamespace(); - const trpc = useTRPC(); const queryClient = useQueryClient(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { isPending, mutate: deleteDocument } = useMutation( - trpc.document.delete.mutationOptions({ + orpc.document.delete.mutationOptions({ onSuccess: () => { logEvent("document_deleted", { documentId: row.original.id, @@ -42,11 +41,12 @@ export default function DocumentActions({ row }: { row: Row }) { }); toast.success("Document queued for deletion"); setDeleteDialogOpen(false); - void queryClient.invalidateQueries( - trpc.document.all.queryFilter({ - namespaceId: namespace.id, + void queryClient.invalidateQueries({ + queryKey: orpc.document.all.key({ + input: { namespaceId: namespace.id }, + type: "query", }), - ); + }); }, onError: (error) => { toast.error(error.message); @@ -55,7 +55,7 @@ export default function DocumentActions({ row }: { row: Row }) { ); const { isPending: isDownloading, mutate: getDownloadUrl } = useMutation( - trpc.document.getFileDownloadUrl.mutationOptions({ + orpc.document.getFileDownloadUrl.mutationOptions({ onSuccess: ({ url }) => { window.open(url, "_blank"); }, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/index.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/index.tsx index 82301b63..46e828b7 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/index.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQueryClient } from "@tanstack/react-query"; import { PlusIcon } from "lucide-react"; import { toast } from "sonner"; @@ -43,21 +43,25 @@ export function IngestModal() { const [isOpen, setIsOpen] = useState(false); const [activeTab, setActiveTab] = useState<(typeof TABS)[number]["value"]>("files"); - const trpc = useTRPC(); const queryClient = useQueryClient(); const organization = useOrganization(); const namespace = useNamespace(); const handleSuccess = useCallback(() => { setIsOpen(false); - void queryClient.invalidateQueries( - trpc.ingestJob.all.queryFilter({ namespaceId: namespace.id }), - ); + void queryClient.invalidateQueries({ + queryKey: orpc.ingestJob.all.key({ + input: { namespaceId: namespace.id }, + type: "query", + }), + }); toast.success(SUCCESS_MESSAGES[activeTab]); - }, [queryClient, trpc.ingestJob.all, namespace.id, activeTab]); + }, [queryClient, namespace.id, activeTab]); const isPending = - queryClient.isMutating(trpc.ingestJob.ingest.mutationOptions()) > 0; + queryClient.isMutating({ + mutationKey: orpc.ingestJob.ingest.mutationKey(), + }) > 0; const isOverLimit = isFreePlan(organization.plan) && diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/use-ingest.ts b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/use-ingest.ts index e40a94bd..c2c4ce43 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/use-ingest.ts +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/use-ingest.ts @@ -1,6 +1,6 @@ import { useNamespace } from "@/hooks/use-namespace"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation } from "@tanstack/react-query"; import { IngestJobConfig, IngestJobPayload } from "@agentset/validation"; @@ -42,10 +42,9 @@ export function useIngest({ extraAnalytics, }: UseIngestOptions) { const namespace = useNamespace(); - const trpc = useTRPC(); const mutation = useMutation( - trpc.ingestJob.ingest.mutationOptions({ + orpc.ingestJob.ingest.mutationOptions({ onSuccess: (doc) => { logEvent( "document_ingested", diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-documents.ts b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-documents.ts index 3ea1d503..01c6e69c 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-documents.ts +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-documents.ts @@ -1,7 +1,7 @@ import { useState } from "react"; import { useCursorPagination } from "@/hooks/use-cursor-pagination"; import { useNamespace } from "@/hooks/use-namespace"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { DocumentStatus } from "@agentset/db/browser"; @@ -14,7 +14,6 @@ const statusLabels = Object.values(DocumentStatus).map((status) => ({ export function useDocuments(jobId?: string, enabled = true) { const namespace = useNamespace(); - const trpc = useTRPC(); const [statuses, _setStatuses] = useState([]); const { cursor, @@ -26,20 +25,18 @@ export function useDocuments(jobId?: string, enabled = true) { } = useCursorPagination(); const { isLoading, data, refetch, isFetching } = useQuery( - trpc.document.all.queryOptions( - { + orpc.document.all.queryOptions({ + input: { namespaceId: namespace.id, ingestJobId: jobId, statuses: statuses.length > 0 ? statuses.join(",") : undefined, cursor, cursorDirection, }, - { - placeholderData: keepPreviousData, - refetchInterval: 15_000, // Refetch every 15 seconds - enabled, - }, - ), + placeholderData: keepPreviousData, + refetchInterval: 15_000, // Refetch every 15 seconds + enabled, + }), ); const setStatuses = (statuses: DocumentStatus[]) => { diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-jobs.ts b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-jobs.ts index 4114be58..d58edcc4 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-jobs.ts +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-jobs.ts @@ -1,7 +1,7 @@ import { useState } from "react"; import { useCursorPagination } from "@/hooks/use-cursor-pagination"; import { useNamespace } from "@/hooks/use-namespace"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { IngestJobStatus } from "@agentset/db/browser"; @@ -14,7 +14,6 @@ const statusLabels = Object.values(IngestJobStatus).map((status) => ({ export function useJobs(enabled: boolean) { const namespace = useNamespace(); - const trpc = useTRPC(); const [statuses, _setStatuses] = useState([]); const [expandedJobId, setExpandedJobId] = useState(null); const { @@ -27,19 +26,17 @@ export function useJobs(enabled: boolean) { } = useCursorPagination(); const { isLoading, data, refetch, isFetching } = useQuery( - trpc.ingestJob.all.queryOptions( - { + orpc.ingestJob.all.queryOptions({ + input: { namespaceId: namespace.id, statuses: statuses.length > 0 ? statuses.join(",") : undefined, cursor, cursorDirection, }, - { - enabled, - refetchInterval: 15_000, // Refetch every 15 seconds - placeholderData: keepPreviousData, - }, - ), + enabled, + refetchInterval: 15_000, // Refetch every 15 seconds + placeholderData: keepPreviousData, + }), ); const setStatuses = (statuses: IngestJobStatus[]) => { diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-pending-jobs.ts b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-pending-jobs.ts index 6c882a69..5e2fc07b 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-pending-jobs.ts +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/use-pending-jobs.ts @@ -1,5 +1,5 @@ import { useNamespace } from "@/hooks/use-namespace"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { IngestJobStatus } from "@agentset/db/browser"; @@ -17,20 +17,17 @@ const PENDING_STATUSES: IngestJobStatus[] = [ export function useHasPendingJobs(enabled: boolean) { const namespace = useNamespace(); - const trpc = useTRPC(); const { data } = useQuery( - trpc.ingestJob.all.queryOptions( - { + orpc.ingestJob.all.queryOptions({ + input: { namespaceId: namespace.id, statuses: PENDING_STATUSES.join(","), perPage: 1, }, - { - enabled, - refetchInterval: 15_000, // Refetch every 15 seconds - placeholderData: keepPreviousData, - }, - ), + enabled, + refetchInterval: 15_000, // Refetch every 15 seconds + placeholderData: keepPreviousData, + }), ); return data ? data.records.length > 0 : false; diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx index 4eb304a7..26a51190 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; import { logEvent } from "@/lib/analytics"; import { SHORT_DOMAIN } from "@/lib/constants"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircleIcon, @@ -26,15 +26,12 @@ const CNAME_VALUE = `cname.${SHORT_DOMAIN}`; const A_VALUE = "76.76.21.21"; export function useDomainStatus() { - const trpc = useTRPC(); const namespace = useNamespace(); const { data, isFetching, refetch } = useQuery( - trpc.domain.checkStatus.queryOptions( - { namespaceId: namespace.id }, - { - refetchInterval: 20000, - }, - ), + orpc.domain.checkStatus.queryOptions({ + input: { namespaceId: namespace.id }, + refetchInterval: 20000, + }), ); return { @@ -231,21 +228,20 @@ const DomainControls = ({ }) => { const { refetch, loading } = useDomainStatus(); const namespace = useNamespace(); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutate: removeDomain, isPending: isRemovingDomain } = useMutation( - trpc.domain.remove.mutationOptions({ + orpc.domain.remove.mutationOptions({ onSuccess: () => { logEvent("domain_removed", { domain, namespaceId: namespace.id, }); toast.success("Domain removed successfully"); - void queryClient.invalidateQueries( - trpc.hosting.get.queryOptions({ - namespaceId: namespace.id, + void queryClient.invalidateQueries({ + queryKey: orpc.hosting.get.key({ + input: { namespaceId: namespace.id }, }), - ); + }); onRemove(); }, }), @@ -285,11 +281,10 @@ export function CustomDomainConfigurator(props: { defaultDomain?: string }) { props.defaultDomain ?? "", ); - const trpc = useTRPC(); const namespace = useNamespace(); const { mutate: addDomain, isPending } = useMutation( - trpc.domain.add.mutationOptions({ + orpc.domain.add.mutationOptions({ onSuccess: (data) => { logEvent("domain_added", { domain: data.slug, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx index 346a2be9..9446055f 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx @@ -1,6 +1,6 @@ import { useNamespace } from "@/hooks/use-namespace"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { Button } from "@agentset/ui/button"; import { @@ -19,17 +19,18 @@ import { toast } from "sonner"; export function EmptyState() { const namespace = useNamespace(); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutate: enableHosting, isPending } = useMutation( - trpc.hosting.enable.mutationOptions({ + orpc.hosting.enable.mutationOptions({ onSuccess: (hosting) => { logEvent("hosting_enabled", { namespaceId: namespace.id, }); toast.success("Hosting enabled successfully"); queryClient.setQueryData( - trpc.hosting.get.queryKey({ namespaceId: namespace.id }), + orpc.hosting.get.queryKey({ + input: { namespaceId: namespace.id }, + }), () => { return { ...hosting, domain: null }; }, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx index f6d2583e..478ff93b 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx @@ -1,7 +1,7 @@ "use client"; import { useNamespace } from "@/hooks/use-namespace"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { Skeleton } from "@agentset/ui/skeleton"; @@ -11,10 +11,9 @@ import { EmptyState } from "./empty-state"; export default function HostingPageClient() { const namespace = useNamespace(); - const trpc = useTRPC(); const { data, isLoading } = useQuery( - trpc.hosting.get.queryOptions({ - namespaceId: namespace.id, + orpc.hosting.get.queryOptions({ + input: { namespaceId: namespace.id }, }), ); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/use-hosting-form.ts b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/use-hosting-form.ts index 4ab943cb..31b0b42d 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/use-hosting-form.ts +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/use-hosting-form.ts @@ -1,7 +1,7 @@ import { useNamespace } from "@/hooks/use-namespace"; import { logEvent } from "@/lib/analytics"; +import { orpc, type RouterOutputs } from "@/lib/orpc"; import { DEFAULT_SYSTEM_PROMPT } from "@/lib/prompts"; -import { RouterOutputs, useTRPC } from "@/trpc/react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; @@ -43,11 +43,10 @@ export type HostingData = NonNullable; export function useHostingForm(data: HostingData) { const namespace = useNamespace(); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutateAsync: updateHosting, isPending: isUpdating } = useMutation( - trpc.hosting.update.mutationOptions({ + orpc.hosting.update.mutationOptions({ onSuccess: (result) => { logEvent("hosting_updated", { namespaceId: namespace.id, @@ -61,8 +60,8 @@ export function useHostingForm(data: HostingData) { }); toast.success("Hosting settings saved"); queryClient.setQueryData( - trpc.hosting.get.queryKey({ - namespaceId: namespace.id, + orpc.hosting.get.queryKey({ + input: { namespaceId: namespace.id }, }), (old) => { return { @@ -73,11 +72,11 @@ export function useHostingForm(data: HostingData) { }, ); - queryClient.invalidateQueries( - trpc.hosting.get.queryOptions({ - namespaceId: namespace.id, + queryClient.invalidateQueries({ + queryKey: orpc.hosting.get.key({ + input: { namespaceId: namespace.id }, }), - ); + }); }, onError: (error) => { toast.error(error.message); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/api-dialog.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/api-dialog.tsx index 0a341567..192b4c89 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/api-dialog.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/api-dialog.tsx @@ -1,6 +1,6 @@ import { useNamespace } from "@/hooks/use-namespace"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { ArrowUpRightIcon } from "lucide-react"; @@ -33,13 +33,12 @@ export default function ApiDialog({ }) { const namespace = useNamespace(); const organization = useOrganization(); - const trpc = useTRPC(); const { data: defaultApiKey } = useQuery( - trpc.apiKey.getDefaultApiKey.queryOptions( - { orgId: organization.id }, - { enabled: !organization.isLoading }, - ), + orpc.apiKey.getDefaultApiKey.queryOptions({ + input: { orgId: organization.id }, + enabled: !organization.isLoading, + }), ); const prepareExample = (example: (apiKey?: string) => string) => { diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx index e69ac8c6..e9b6bba2 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx @@ -5,7 +5,7 @@ import { RerankerSelector } from "@/components/reranker-selector"; import SearchChunk from "@/components/search-chunk"; import { useNamespace } from "@/hooks/use-namespace"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { SearchIcon } from "lucide-react"; @@ -38,11 +38,10 @@ export default function ChunkExplorerPageClient() { const [rerankModel, setRerankModel] = useState(DEFAULT_RERANKER); const [rerankLimit, setRerankLimit] = useState(20); - const trpc = useTRPC(); const { data, isLoading, isFetching, error, isEnabled } = useQuery( - trpc.search.search.queryOptions( - { + orpc.search.search.queryOptions({ + input: { namespaceId: namespace.id, query: searchQuery, topK, @@ -50,12 +49,10 @@ export default function ChunkExplorerPageClient() { rerankModel, rerankLimit, }, - { - enabled: searchQuery.length > 0, - refetchOnWindowFocus: false, - staleTime: Infinity, - }, - ), + enabled: searchQuery.length > 0, + refetchOnWindowFocus: false, + staleTime: Infinity, + }), ); const handleSubmit = (e: React.FormEvent) => { diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/quick-start/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/quick-start/page.client.tsx index b84876ab..4616369a 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/quick-start/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/quick-start/page.client.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useNamespace } from "@/hooks/use-namespace"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import type { OnboardingStatus } from "./onboarding-progress"; @@ -10,19 +10,16 @@ import NamespaceOnboardingProgress from "./onboarding-progress"; export default function GetStartedClientPage() { const { baseUrl, organization, ...activeNamespace } = useNamespace(); - const trpc = useTRPC(); const { data: onboardingStatus } = useQuery( - trpc.namespace.getOnboardingStatus.queryOptions( - { + orpc.namespace.getOnboardingStatus.queryOptions({ + input: { orgSlug: organization.slug, slug: activeNamespace.slug, }, - { - refetchOnMount: true, - refetchOnWindowFocus: true, - }, - ), + refetchOnMount: true, + refetchOnWindowFocus: true, + }), ); const steps = useMemo(() => { diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx index b1afc249..3eee488f 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx @@ -4,7 +4,7 @@ import { DeleteConfirmation } from "@/components/delete-confirmation"; import { useNamespace } from "@/hooks/use-namespace"; import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -15,11 +15,10 @@ export function DeleteNamespaceButton() { const namespace = useNamespace(); const organization = useOrganization(); const router = useRouter(); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutate: deleteNamespace, isPending } = useMutation( - trpc.namespace.deleteNamespace.mutationOptions({ + orpc.namespace.deleteNamespace.mutationOptions({ onSuccess: () => { logEvent("namespace_deleted", { id: namespace.id, @@ -27,8 +26,8 @@ export function DeleteNamespaceButton() { organizationId: organization.id, }); toast.success("Namespace deleted"); - const queryKey = trpc.namespace.getOrgNamespaces.queryKey({ - slug: organization.slug, + const queryKey = orpc.namespace.getOrgNamespaces.queryKey({ + input: { slug: organization.slug }, }); queryClient.setQueryData(queryKey, (old) => { if (!old) return old; diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/payment-methods/index.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/payment-methods/index.tsx index 22d888f7..890380ab 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/payment-methods/index.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/payment-methods/index.tsx @@ -1,7 +1,7 @@ "use client"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCardIcon } from "lucide-react"; @@ -31,23 +31,22 @@ export default function PaymentMethods() { function PaymentMethodsInner() { const router = useRouter(); const organization = useOrganization(); - const trpc = useTRPC(); const { data: paymentMethods, isLoading, isEnabled, } = useQuery( - trpc.billing.getPaymentMethods.queryOptions( - { + orpc.billing.getPaymentMethods.queryOptions({ + input: { orgId: organization.id, }, - { enabled: !!organization.stripeId }, - ), + enabled: !!organization.stripeId, + }), ); const { mutateAsync: addPaymentMethod, isPending: isAdding } = useMutation( - trpc.billing.addPaymentMethod.mutationOptions({ + orpc.billing.addPaymentMethod.mutationOptions({ onError: (error) => { toast.error(error.message); }, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/usage/index.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/usage/index.tsx index 9f254a5b..a666983f 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/usage/index.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/usage/index.tsx @@ -3,8 +3,8 @@ import { useMemo } from "react"; import Link from "next/link"; import { useOrganization } from "@/hooks/use-organization"; +import { orpc } from "@/lib/orpc"; import { formatNumber } from "@/lib/utils"; -import { useTRPC } from "@/trpc/react"; import NumberFlow from "@number-flow/react"; import { useQuery } from "@tanstack/react-query"; import { BookIcon, PlugIcon, SearchIcon } from "lucide-react"; @@ -119,13 +119,12 @@ export default function PlanUsage() { function PagesCard() { const organization = useOrganization(); - const trpc = useTRPC(); const isEnabled = !isFreePlan(organization.plan) && !!organization.stripeId; const { data: tracked, isLoading: isLoadingTracked } = useQuery( - trpc.billing.getTrackedPages.queryOptions( - { orgId: organization.id }, - { enabled: isEnabled }, - ), + orpc.billing.getTrackedPages.queryOptions({ + input: { orgId: organization.id }, + enabled: isEnabled, + }), ); const currentPages = organization.totalPages; diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/usage/subscription-menu.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/usage/subscription-menu.tsx index af463716..cd8dc105 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/usage/subscription-menu.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/_components/usage/subscription-menu.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation } from "@tanstack/react-query"; import { CalendarSyncIcon, MoreVerticalIcon, XIcon } from "lucide-react"; @@ -20,11 +20,10 @@ export default function SubscriptionMenu() { const [isOpen, setIsOpen] = useState(false); const { id } = useOrganization(); const router = useRouter(); - const trpc = useTRPC(); const { mutateAsync: cancelSubscription, isPending: isCancelling } = useMutation( - trpc.billing.cancel.mutationOptions({ + orpc.billing.cancel.mutationOptions({ onError: (error) => { toast.error(error.message); }, @@ -33,7 +32,7 @@ export default function SubscriptionMenu() { const { mutateAsync: manageSubscription, isPending: isManaging } = useMutation( - trpc.billing.manage.mutationOptions({ + orpc.billing.manage.mutationOptions({ onError: (error) => { toast.error(error.message); }, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/invoices/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/invoices/page.client.tsx index 20018cf0..8af64848 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/invoices/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/invoices/page.client.tsx @@ -1,10 +1,10 @@ "use client"; -import type { RouterOutputs } from "@/trpc/react"; +import type { RouterOutputs } from "@/lib/orpc"; import Link from "next/link"; import { useOrganization } from "@/hooks/use-organization"; +import { orpc } from "@/lib/orpc"; import { formatNumber } from "@/lib/utils"; -import { useTRPC } from "@/trpc/react"; import { useQuery } from "@tanstack/react-query"; import { ChevronLeftIcon, DollarSignIcon, ReceiptTextIcon } from "lucide-react"; @@ -17,18 +17,17 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@agentset/ui/tooltip"; export default function OrganizationInvoicesClient() { const organization = useOrganization(); - const trpc = useTRPC(); const { data: invoices, isLoading, isEnabled, } = useQuery( - trpc.billing.invoices.queryOptions( - { + orpc.billing.invoices.queryOptions({ + input: { orgId: organization.id, }, - { enabled: !!organization.stripeId }, - ), + enabled: !!organization.stripeId, + }), ); return ( diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/upgrade/upgrade-button.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/upgrade/upgrade-button.tsx index 52359926..25e8539f 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/upgrade/upgrade-button.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/billing/upgrade/upgrade-button.tsx @@ -4,8 +4,8 @@ import type { ComponentProps } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; +import { orpc } from "@/lib/orpc"; import { getBaseUrl } from "@/lib/utils"; -import { useTRPC } from "@/trpc/react"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -26,11 +26,10 @@ export function UpgradePlanButton({ const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const trpc = useTRPC(); const organization = useOrganization(); const { mutateAsync, isPending } = useMutation( - trpc.billing.upgrade.mutationOptions({ + orpc.billing.upgrade.mutationOptions({ onSuccess: async (data) => { if (data.url) { router.push(data.url); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/namespaces-empty-state.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/namespaces-empty-state.tsx index 5bf6693e..049a052e 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/namespaces-empty-state.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/namespaces-empty-state.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -63,7 +63,6 @@ export function NamespacesEmptyState({ onCreateClick: () => void; }) { const router = useRouter(); - const trpc = useTRPC(); const organization = useOrganization(); const [creatingTemplateId, setCreatingTemplateId] = useState(null); @@ -71,10 +70,10 @@ export function NamespacesEmptyState({ const { mutate: createDemoNamespace, isPending: isCreatingDemo } = useMutation( - trpc.namespace.createDemoNamespace.mutationOptions({ + orpc.namespace.createDemoNamespace.mutationOptions({ onSuccess: (namespace) => { - const queryKey = trpc.namespace.getOrgNamespaces.queryKey({ - slug: organization.slug, + const queryKey = orpc.namespace.getOrgNamespaces.queryKey({ + input: { slug: organization.slug }, }); void queryClient.invalidateQueries({ queryKey }); router.push(`/${organization.slug}/${namespace.slug}/documents`); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/page.client.tsx index 3c6fd746..5787e1e9 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/page.client.tsx @@ -4,8 +4,8 @@ import { useState } from "react"; import Link from "next/link"; import CreateNamespaceDialog from "@/components/create-namespace"; import { useOrganization } from "@/hooks/use-organization"; +import { orpc } from "@/lib/orpc"; import { formatNumber } from "@/lib/utils"; -import { useTRPC } from "@/trpc/react"; import { useQuery } from "@tanstack/react-query"; import { PlusIcon } from "lucide-react"; @@ -20,14 +20,13 @@ export default function DashboardPage() { const organization = useOrganization(); const [open, setOpen] = useState(false); - const trpc = useTRPC(); const { data: namespaces, isLoading, error, } = useQuery( - trpc.namespace.getOrgNamespaces.queryOptions({ - slug: organization.slug, + orpc.namespace.getOrgNamespaces.queryOptions({ + input: { slug: organization.slug }, }), ); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/actions.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/actions.tsx index 4c0b52d9..01efdb28 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/actions.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/actions.tsx @@ -1,6 +1,6 @@ import type { Row } from "@tanstack/react-table"; import { DeleteConfirmation } from "@/components/delete-confirmation"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { EllipsisVerticalIcon, Trash2Icon } from "lucide-react"; import { toast } from "sonner"; @@ -16,20 +16,23 @@ import { import type { ApiKeyDef } from "./columns"; export function ApiKeyActions({ row }: { row: Row }) { - const trpc = useTRPC(); const queryClient = useQueryClient(); const orgId = row.original.organizationId; const id = row.original.id; const { mutateAsync: deleteApiKey, isPending } = useMutation( - trpc.apiKey.deleteApiKey.mutationOptions({ + orpc.apiKey.deleteApiKey.mutationOptions({ onSuccess: () => { - const queryFilter = trpc.apiKey.getApiKeys.queryFilter({ orgId }); - queryClient.setQueryData(queryFilter.queryKey, (old) => { + const queryKey = orpc.apiKey.getApiKeys.queryKey({ + input: { orgId }, + }); + queryClient.setQueryData(queryKey, (old) => { if (!old) return []; return old.filter((key) => key.id !== id); }); - void queryClient.invalidateQueries(queryFilter); + void queryClient.invalidateQueries({ + queryKey: orpc.apiKey.getApiKeys.key({ input: { orgId } }), + }); toast.success("API key deleted"); }, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx index 90751251..b339f0c6 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { PlusIcon } from "lucide-react"; import { toast } from "sonner"; @@ -30,26 +30,29 @@ export default function CreateApiKey({ orgId }: { orgId: string }) { const [isOpen, setIsOpen] = useState(false); const [label, setLabel] = useState(""); const [scope, setScope] = useState<"all">("all"); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { isPending, mutateAsync, data, reset } = useMutation( - trpc.apiKey.createApiKey.mutationOptions({ + orpc.apiKey.createApiKey.mutationOptions({ onSuccess: (newKey) => { logEvent("api_key_created", { orgId, scope: newKey.scope, }); - const queryFilter = trpc.apiKey.getApiKeys.queryFilter({ orgId }); + const queryKey = orpc.apiKey.getApiKeys.queryKey({ + input: { orgId }, + }); - queryClient.setQueryData(queryFilter.queryKey, (old) => { + queryClient.setQueryData(queryKey, (old) => { if (!old) return []; return [...old, newKey]; }); toast.success("API key created"); - void queryClient.invalidateQueries(queryFilter); + void queryClient.invalidateQueries({ + queryKey: orpc.apiKey.getApiKeys.key({ input: { orgId } }), + }); }, onError: (error) => { toast.error(error.message); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/page.client.tsx index 7877ff13..e8a39dbb 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/page.client.tsx @@ -1,7 +1,7 @@ "use client"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { DataTable } from "@agentset/ui/data-table"; @@ -28,10 +28,9 @@ export default function ApiKeysPage() { } function ApiKeysList({ orgId }: { orgId: string }) { - const trpc = useTRPC(); const { data, isLoading } = useQuery( - trpc.apiKey.getApiKeys.queryOptions({ - orgId, + orpc.apiKey.getApiKeys.queryOptions({ + input: { orgId }, }), ); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/danger/delete-org-button.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/danger/delete-org-button.tsx index 632ad0ba..7a626dcf 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/danger/delete-org-button.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/danger/delete-org-button.tsx @@ -2,7 +2,7 @@ import { DeleteConfirmation } from "@/components/delete-confirmation"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -12,9 +12,8 @@ import { Button } from "@agentset/ui/button"; export function DeleteOrgButton() { const organization = useOrganization(); const router = useRouter(); - const trpc = useTRPC(); const { mutate: deleteOrganization, isPending } = useMutation( - trpc.organization.delete.mutationOptions({ + orpc.organization.delete.mutationOptions({ onSuccess: () => { toast.success("Organization deleted"); router.push("/"); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/page.client.tsx index 60be0eed..78db2b8d 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/page.client.tsx @@ -5,7 +5,7 @@ import { useOrganization } from "@/hooks/use-organization"; import { useZodForm } from "@/hooks/use-zod-form"; import { logEvent } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -62,7 +62,6 @@ export default function SettingsPage() { }, }); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutateAsync: updateOrganization, isPending } = useMutation({ mutationFn: async (data: z.infer) => { @@ -83,11 +82,12 @@ export default function SettingsPage() { }); // Invalidate the organization query to refetch updated data - queryClient.invalidateQueries( - trpc.organization.getBySlug.queryFilter({ - slug: organization.slug, + queryClient.invalidateQueries({ + queryKey: orpc.organization.getBySlug.key({ + input: { slug: organization.slug }, + type: "query", }), - ); + }); toast.success("Organization updated"); // if slug changed, redirect to the new slug diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/invite-dialog.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/invite-dialog.tsx index f43a3d0a..dfd08714 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/invite-dialog.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/invite-dialog.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { PlusIcon } from "lucide-react"; import { toast } from "sonner"; @@ -37,7 +37,6 @@ function InviteMemberDialog() { const [email, setEmail] = useState(""); const [role, setRole] = useState("member"); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutateAsync: invite, isPending } = useMutation({ mutationFn: async ({ email, role }: { email: string; role: string }) => { @@ -49,11 +48,11 @@ function InviteMemberDialog() { }); }, onSuccess: (result) => { - const queryFilter = trpc.organization.members.queryFilter({ - organizationId: id, + const queryKey = orpc.organization.members.queryKey({ + input: { organizationId: id }, }); - queryClient.setQueryData(queryFilter.queryKey, (old) => { + queryClient.setQueryData(queryKey, (old) => { if (!old) return old; return { @@ -62,7 +61,12 @@ function InviteMemberDialog() { }; }); - void queryClient.invalidateQueries(queryFilter); + void queryClient.invalidateQueries({ + queryKey: orpc.organization.members.key({ + input: { organizationId: id }, + type: "query", + }), + }); setOpen(false); }, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/member-card.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/member-card.tsx index 4036f536..e562f2f9 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/member-card.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/member-card.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -33,7 +33,6 @@ export const MemberCard = ({ const [role, setRole] = useState(initialRole); const { id: organizationId, currentMemberId } = useOrganization(); const queryClient = useQueryClient(); - const trpc = useTRPC(); const { mutateAsync: updateMemberRole, isPending: isUpdatingRole } = useMutation({ @@ -55,11 +54,12 @@ export const MemberCard = ({ newRole: result.role, }); - queryClient.invalidateQueries( - trpc.organization.members.queryFilter({ - organizationId, + queryClient.invalidateQueries({ + queryKey: orpc.organization.members.key({ + input: { organizationId }, + type: "query", }), - ); + }); }, onError: () => { toast.error("Failed to update member role"); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/page.client.tsx index 36bf6459..f26ea9ad 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/page.client.tsx @@ -3,7 +3,7 @@ import { useMemo } from "react"; import { useOrganization } from "@/hooks/use-organization"; import { useSession } from "@/hooks/use-session"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { CopyButton } from "@agentset/ui/copy-button"; @@ -17,11 +17,10 @@ import { RevokeInvitationButton } from "./revoke-invitation"; export default function TeamSettingsPage() { const organization = useOrganization(); const { session } = useSession(); - const trpc = useTRPC(); const { data, isLoading: membersLoading } = useQuery( - trpc.organization.members.queryOptions({ - organizationId: organization.id, + orpc.organization.members.queryOptions({ + input: { organizationId: organization.id }, }), ); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/remove-member.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/remove-member.tsx index 179ed42a..be771db0 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/remove-member.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/remove-member.tsx @@ -1,7 +1,7 @@ import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -18,7 +18,6 @@ export const RemoveMemberButton = ({ const { id } = useOrganization(); const router = useRouter(); const isCurrentMember = currentMemberId === memberId; - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutateAsync: removeMember, isPending: isRemoving } = useMutation({ @@ -30,11 +29,11 @@ export const RemoveMemberButton = ({ }); }, onSuccess: () => { - const queryFilter = trpc.organization.members.queryFilter({ - organizationId: id, + const queryKey = orpc.organization.members.queryKey({ + input: { organizationId: id }, }); - queryClient.setQueryData(queryFilter.queryKey, (old) => { + queryClient.setQueryData(queryKey, (old) => { if (!old) return old; return { @@ -42,7 +41,12 @@ export const RemoveMemberButton = ({ members: old.members.filter((m) => m.id !== memberId), }; }); - void queryClient.invalidateQueries(queryFilter); + void queryClient.invalidateQueries({ + queryKey: orpc.organization.members.key({ + input: { organizationId: id }, + type: "query", + }), + }); toast.success("Member removed successfully"); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/revoke-invitation.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/revoke-invitation.tsx index ff57f7be..1d2fa656 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/revoke-invitation.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/team/revoke-invitation.tsx @@ -1,7 +1,7 @@ import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -13,7 +13,6 @@ export const RevokeInvitationButton = ({ invitationId: string; }) => { const { id } = useOrganization(); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutateAsync: revokeInvitation, isPending: isRevoking } = useMutation({ @@ -24,11 +23,11 @@ export const RevokeInvitationButton = ({ }); }, onSuccess: (data) => { - const queryFilter = trpc.organization.members.queryFilter({ - organizationId: id, + const queryKey = orpc.organization.members.queryKey({ + input: { organizationId: id }, }); - queryClient.setQueryData(queryFilter.queryKey, (old) => { + queryClient.setQueryData(queryKey, (old) => { if (!old) return old; return { @@ -37,7 +36,12 @@ export const RevokeInvitationButton = ({ }; }); - void queryClient.invalidateQueries(queryFilter); + void queryClient.invalidateQueries({ + queryKey: orpc.organization.members.key({ + input: { organizationId: id }, + type: "query", + }), + }); toast.success("Invitation revoked successfully"); }, }); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/webhooks/[webhookId]/edit/page.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/webhooks/[webhookId]/edit/page.tsx index 40a1a9b9..6611ac41 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/webhooks/[webhookId]/edit/page.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/webhooks/[webhookId]/edit/page.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/navigation"; import AddEditWebhookForm from "@/components/webhooks/add-edit-webhook-form"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { Skeleton } from "@agentset/ui/skeleton"; @@ -11,12 +11,13 @@ import { Skeleton } from "@agentset/ui/skeleton"; export default function EditWebhookPage() { const params = useParams<{ webhookId: string }>(); const organization = useOrganization(); - const trpc = useTRPC(); const { data: webhook, isLoading } = useQuery( - trpc.webhook.get.queryOptions({ - organizationId: organization.id, - webhookId: params.webhookId, + orpc.webhook.get.queryOptions({ + input: { + organizationId: organization.id, + webhookId: params.webhookId, + }, }), ); diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index 258fe8df..93d815d6 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -1,7 +1,8 @@ "use client"; -import { TRPCReactProvider } from "@/trpc/react"; +import { getQueryClient } from "@/lib/query-client"; import { ProgressProvider } from "@bprogress/next/app"; +import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@agentset/ui/sonner"; import { ThemeProvider } from "@agentset/ui/theme-provider"; @@ -21,9 +22,9 @@ export default function Providers({ children }: { children: React.ReactNode }) { options={{ showSpinner: false }} shallowRouting > - + {children} - + diff --git a/apps/web/src/components/app-sidebar/org-switcher/index.tsx b/apps/web/src/components/app-sidebar/org-switcher/index.tsx index 51dccfe8..fe5190a5 100644 --- a/apps/web/src/components/app-sidebar/org-switcher/index.tsx +++ b/apps/web/src/components/app-sidebar/org-switcher/index.tsx @@ -1,11 +1,11 @@ "use client"; -import type { RouterOutputs } from "@/trpc/react"; +import type { RouterOutputs } from "@/lib/orpc"; import React, { useState } from "react"; import Link from "next/link"; import { useOrganization } from "@/hooks/use-organization"; import { authClient } from "@/lib/auth-client"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ChevronsUpDownIcon, PlusIcon, SettingsIcon } from "lucide-react"; @@ -37,9 +37,8 @@ export function OrganizationSwitcher() { const router = useRouter(); const activeOrganization = useOrganization(); - const trpc = useTRPC(); const { data: organizations } = useQuery( - trpc.organization.all.queryOptions(), + orpc.organization.all.queryOptions(), ); const [createOrgOpen, setCreateOrgOpen] = useState(false); diff --git a/apps/web/src/components/create-namespace/details-step.tsx b/apps/web/src/components/create-namespace/details-step.tsx index 944775dc..ef3cb60d 100644 --- a/apps/web/src/components/create-namespace/details-step.tsx +++ b/apps/web/src/components/create-namespace/details-step.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from "react"; import { useOrganization } from "@/hooks/use-organization"; import { useZodForm } from "@/hooks/use-zod-form"; -import { trpcClient } from "@/trpc/react"; +import { client } from "@/lib/orpc"; import { z } from "zod/v4"; import { Button } from "@agentset/ui/button"; @@ -28,7 +28,7 @@ const createFormSchema = (orgId: string) => .refine( async (value) => { if (value === "") return false; - const result = await trpcClient.namespace.checkSlug.query({ + const result = await client.namespace.checkSlug({ slug: value, orgId, }); diff --git a/apps/web/src/components/create-namespace/index.tsx b/apps/web/src/components/create-namespace/index.tsx index c69639a7..62bb57e9 100644 --- a/apps/web/src/components/create-namespace/index.tsx +++ b/apps/web/src/components/create-namespace/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -68,7 +68,6 @@ export default function CreateNamespaceDialog({ setOpen: (open: boolean) => void; }) { const router = useRouter(); - const trpc = useTRPC(); const queryClient = useQueryClient(); const [step, setStep] = useState("details"); @@ -88,7 +87,7 @@ export default function CreateNamespaceDialog({ const [slug, setSlug] = useState(defaultSlug); const { isPending, mutateAsync: createNamespace } = useMutation( - trpc.namespace.createNamespace.mutationOptions({ + orpc.namespace.createNamespace.mutationOptions({ onSuccess: (data) => { logEvent("namespace_created", { name: data.name, @@ -115,8 +114,8 @@ export default function CreateNamespaceDialog({ setStep("details"); if (organization) { - const queryKey = trpc.namespace.getOrgNamespaces.queryKey({ - slug: organization.slug, + const queryKey = orpc.namespace.getOrgNamespaces.queryKey({ + input: { slug: organization.slug }, }); queryClient.setQueryData(queryKey, (old) => [data, ...(old ?? [])]); void queryClient.invalidateQueries({ queryKey }); diff --git a/apps/web/src/components/dashboard-page-wrapper/namespace-switcher.tsx b/apps/web/src/components/dashboard-page-wrapper/namespace-switcher.tsx index 09050040..4168ad3d 100644 --- a/apps/web/src/components/dashboard-page-wrapper/namespace-switcher.tsx +++ b/apps/web/src/components/dashboard-page-wrapper/namespace-switcher.tsx @@ -2,7 +2,7 @@ import { useState, useTransition } from "react"; import { useParams, usePathname } from "next/navigation"; import { useNamespace } from "@/hooks/use-namespace"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useRouter } from "@bprogress/next/app"; import { useQuery } from "@tanstack/react-query"; import { ChevronsUpDownIcon, PlusIcon } from "lucide-react"; @@ -41,10 +41,9 @@ export const NamespaceSwitcher = () => { const [open, setOpen] = useState(false); const [isPending, startTransition] = useTransition(); - const trpc = useTRPC(); const { data: namespaces, isLoading } = useQuery( - trpc.namespace.getOrgNamespaces.queryOptions({ - slug: orgSlug, + orpc.namespace.getOrgNamespaces.queryOptions({ + input: { slug: orgSlug }, }), ); diff --git a/apps/web/src/components/webhooks/add-edit-webhook-form.tsx b/apps/web/src/components/webhooks/add-edit-webhook-form.tsx index 3d3bde05..effd4099 100644 --- a/apps/web/src/components/webhooks/add-edit-webhook-form.tsx +++ b/apps/web/src/components/webhooks/add-edit-webhook-form.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { useZodForm } from "@/hooks/use-zod-form"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { InfoIcon, RefreshCwIcon } from "lucide-react"; import { toast } from "sonner"; @@ -61,7 +61,6 @@ export default function AddEditWebhookForm({ }: AddEditWebhookFormProps) { const router = useRouter(); const queryClient = useQueryClient(); - const trpc = useTRPC(); const isEditing = !!webhook; const form = useZodForm(webhookFormSchema, { @@ -74,15 +73,15 @@ export default function AddEditWebhookForm({ }); const { data: namespaces } = useQuery( - trpc.webhook.getNamespaces.queryOptions({ organizationId }), + orpc.webhook.getNamespaces.queryOptions({ input: { organizationId } }), ); const createMutation = useMutation( - trpc.webhook.create.mutationOptions({ + orpc.webhook.create.mutationOptions({ onSuccess: () => { toast.success("Webhook created"); queryClient.invalidateQueries({ - queryKey: trpc.webhook.list.queryKey({ organizationId }), + queryKey: orpc.webhook.list.queryKey({ input: { organizationId } }), }); router.push(`/${organizationSlug}/webhooks`); }, @@ -93,16 +92,18 @@ export default function AddEditWebhookForm({ ); const updateMutation = useMutation( - trpc.webhook.update.mutationOptions({ + orpc.webhook.update.mutationOptions({ onSuccess: () => { toast.success("Webhook updated"); queryClient.invalidateQueries({ - queryKey: trpc.webhook.list.queryKey({ organizationId }), + queryKey: orpc.webhook.list.queryKey({ input: { organizationId } }), }); queryClient.invalidateQueries({ - queryKey: trpc.webhook.get.queryKey({ - organizationId, - webhookId: webhook!.id, + queryKey: orpc.webhook.get.queryKey({ + input: { + organizationId, + webhookId: webhook!.id, + }, }), }); router.push(`/${organizationSlug}/webhooks/${webhook!.id}`); @@ -114,13 +115,15 @@ export default function AddEditWebhookForm({ ); const regenerateSecretMutation = useMutation( - trpc.webhook.regenerateSecret.mutationOptions({ + orpc.webhook.regenerateSecret.mutationOptions({ onSuccess: () => { toast.success("Secret regenerated"); queryClient.invalidateQueries({ - queryKey: trpc.webhook.get.queryKey({ - organizationId, - webhookId: webhook!.id, + queryKey: orpc.webhook.get.queryKey({ + input: { + organizationId, + webhookId: webhook!.id, + }, }), }); }, diff --git a/apps/web/src/components/webhooks/send-test-webhook-modal.tsx b/apps/web/src/components/webhooks/send-test-webhook-modal.tsx index 1e7295ca..65ee0e75 100644 --- a/apps/web/src/components/webhooks/send-test-webhook-modal.tsx +++ b/apps/web/src/components/webhooks/send-test-webhook-modal.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -40,11 +40,10 @@ function SendTestWebhookModal({ webhook, }: SendTestWebhookModalProps) { const organization = useOrganization(); - const trpc = useTRPC(); const [selectedTrigger, setSelectedTrigger] = useState(""); const sendTestMutation = useMutation( - trpc.webhook.sendTest.mutationOptions({ + orpc.webhook.sendTest.mutationOptions({ onSuccess: () => { toast.success("Test webhook sent"); onOpenChange(false); diff --git a/apps/web/src/components/webhooks/webhook-events.tsx b/apps/web/src/components/webhooks/webhook-events.tsx index 469bfae6..b6b1948b 100644 --- a/apps/web/src/components/webhooks/webhook-events.tsx +++ b/apps/web/src/components/webhooks/webhook-events.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { formatDistanceToNow } from "date-fns"; import { @@ -211,14 +211,15 @@ export default function WebhookEvents({ organizationId, webhookId, }: WebhookEventsProps) { - const trpc = useTRPC(); const [selectedEvent, setSelectedEvent] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); const { data: events, isLoading } = useQuery( - trpc.webhook.getEvents.queryOptions({ - organizationId, - webhookId, + orpc.webhook.getEvents.queryOptions({ + input: { + organizationId, + webhookId, + }, }), ); diff --git a/apps/web/src/components/webhooks/webhook-header.tsx b/apps/web/src/components/webhooks/webhook-header.tsx index 7a2e7f2a..6284f1c7 100644 --- a/apps/web/src/components/webhooks/webhook-header.tsx +++ b/apps/web/src/components/webhooks/webhook-header.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { useRouter, useSelectedLayoutSegment } from "next/navigation"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation"; import { useOrganization } from "@/hooks/use-organization"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeftIcon, @@ -41,16 +41,17 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { const router = useRouter(); const organization = useOrganization(); const queryClient = useQueryClient(); - const trpc = useTRPC(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const selectedLayoutSegment = useSelectedLayoutSegment(); const page = selectedLayoutSegment === null ? "" : selectedLayoutSegment; const { data: webhook, isLoading } = useQuery( - trpc.webhook.get.queryOptions({ - organizationId: organization.id, - webhookId, + orpc.webhook.get.queryOptions({ + input: { + organizationId: organization.id, + webhookId, + }, }), ); @@ -58,12 +59,12 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { useSendTestWebhookModal({ webhook }); const deleteMutation = useMutation( - trpc.webhook.delete.mutationOptions({ + orpc.webhook.delete.mutationOptions({ onSuccess: () => { toast.success("Webhook deleted"); queryClient.invalidateQueries({ - queryKey: trpc.webhook.list.queryKey({ - organizationId: organization.id, + queryKey: orpc.webhook.list.queryKey({ + input: { organizationId: organization.id }, }), }); router.push(`/${organization.slug}/webhooks`); @@ -75,20 +76,22 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { ); const toggleMutation = useMutation( - trpc.webhook.toggle.mutationOptions({ + orpc.webhook.toggle.mutationOptions({ onSuccess: () => { toast.success( webhook?.disabledAt ? "Webhook enabled" : "Webhook disabled", ); queryClient.invalidateQueries({ - queryKey: trpc.webhook.get.queryKey({ - organizationId: organization.id, - webhookId, + queryKey: orpc.webhook.get.queryKey({ + input: { + organizationId: organization.id, + webhookId, + }, }), }); queryClient.invalidateQueries({ - queryKey: trpc.webhook.list.queryKey({ - organizationId: organization.id, + queryKey: orpc.webhook.list.queryKey({ + input: { organizationId: organization.id }, }), }); }, diff --git a/apps/web/src/hooks/use-namespace.ts b/apps/web/src/hooks/use-namespace.ts index 5f7b9e0e..610810b7 100644 --- a/apps/web/src/hooks/use-namespace.ts +++ b/apps/web/src/hooks/use-namespace.ts @@ -1,7 +1,7 @@ "use client"; import { useParams } from "next/navigation"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { useOrganization } from "./use-organization"; @@ -10,22 +10,19 @@ export function useNamespace() { const params = useParams(); const slug = params.slug as string; const namespaceSlug = params.namespaceSlug as string; - const trpc = useTRPC(); const org = useOrganization(); const { data, isLoading, error } = useQuery( - trpc.namespace.getNamespaceBySlug.queryOptions( - { + orpc.namespace.getNamespaceBySlug.queryOptions({ + input: { slug: namespaceSlug, orgSlug: slug, }, - { - enabled: !!slug && !!namespaceSlug, - staleTime: Infinity, - refetchOnWindowFocus: false, - refetchOnMount: false, - }, - ), + enabled: !!slug && !!namespaceSlug, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnMount: false, + }), ); const isLoadingState = isLoading || !data || !!error || org.isLoading; diff --git a/apps/web/src/hooks/use-organization.ts b/apps/web/src/hooks/use-organization.ts index bff476fd..625edce6 100644 --- a/apps/web/src/hooks/use-organization.ts +++ b/apps/web/src/hooks/use-organization.ts @@ -1,22 +1,19 @@ import { useParams } from "next/navigation"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; export function useOrganization() { const params = useParams(); const slug = params.slug as string; - const trpc = useTRPC(); const { data, isLoading, error } = useQuery( - trpc.organization.getBySlug.queryOptions( - { slug }, - { - enabled: !!slug, - staleTime: Infinity, - refetchOnWindowFocus: true, - refetchOnMount: false, - }, - ), + orpc.organization.getBySlug.queryOptions({ + input: { slug }, + enabled: !!slug, + staleTime: Infinity, + refetchOnWindowFocus: true, + refetchOnMount: false, + }), ); return { diff --git a/apps/web/src/hooks/use-upload.ts b/apps/web/src/hooks/use-upload.ts index a22b4862..43225e26 100644 --- a/apps/web/src/hooks/use-upload.ts +++ b/apps/web/src/hooks/use-upload.ts @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -42,9 +42,8 @@ const uploadWithProgress = ( }; export function useUploadFiles({ namespaceId }: { namespaceId: string }) { - const trpc = useTRPC(); const { mutateAsync: getPresignedUrls } = useMutation( - trpc.upload.getPresignedUrls.mutationOptions(), + orpc.upload.getPresignedUrls.mutationOptions(), ); const [uploadedFiles, setUploadedFiles] = useState< diff --git a/apps/web/src/hooks/use-webhooks.ts b/apps/web/src/hooks/use-webhooks.ts index 76af3c51..ba204214 100644 --- a/apps/web/src/hooks/use-webhooks.ts +++ b/apps/web/src/hooks/use-webhooks.ts @@ -1,17 +1,13 @@ -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; export function useWebhooks(organizationId: string) { - const trpc = useTRPC(); - const { data, isLoading, error } = useQuery( - trpc.webhook.list.queryOptions( - { organizationId }, - { - enabled: !!organizationId, - staleTime: 60000, // 1 minute - }, - ), + orpc.webhook.list.queryOptions({ + input: { organizationId }, + enabled: !!organizationId, + staleTime: 60000, // 1 minute + }), ); return { diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts new file mode 100644 index 00000000..ed9e7260 --- /dev/null +++ b/apps/web/src/lib/query-client.ts @@ -0,0 +1,22 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 30 * 1000, + }, + }, + }); + +let clientQueryClientSingleton: QueryClient | undefined = undefined; +export const getQueryClient = () => { + if (typeof window === "undefined") { + // Server: always make a new query client + return createQueryClient(); + } + // Browser: use singleton pattern to keep the same query client + return (clientQueryClientSingleton ??= createQueryClient()); +}; diff --git a/apps/web/src/server/api/auth.ts b/apps/web/src/server/api/auth.ts deleted file mode 100644 index 1fc728f5..00000000 --- a/apps/web/src/server/api/auth.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { cache } from "react"; - -import type { ProtectedProcedureContext } from "./trpc"; - -export const getNamespaceByUser = cache( - async ( - ctx: ProtectedProcedureContext, - idOrSlug: - | { - id: string; - } - | { - slug: string; - }, - ) => { - return await ctx.db.namespace.findFirst({ - where: { - ...("id" in idOrSlug ? { id: idOrSlug.id } : { slug: idOrSlug.slug }), - organization: { - members: { some: { userId: ctx.session.user.id } }, - }, - }, - }); - }, -); diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts deleted file mode 100644 index 7b1a5f97..00000000 --- a/apps/web/src/server/api/root.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; - -import { apiKeysRouter } from "./routers/api-keys"; -import { billingRouter } from "./routers/billing"; -import { documentsRouter } from "./routers/documents"; -import { domainsRouter } from "./routers/domains"; -import { hostingRouter } from "./routers/hosting"; -import { ingestJobRouter } from "./routers/ingest-jobs"; -import { namespaceRouter } from "./routers/namespaces"; -import { organizationsRouter } from "./routers/organizations"; -import { searchRouter } from "./routers/search"; -import { uploadsRouter } from "./routers/uploads"; -import { webhooksRouter } from "./routers/webhooks"; - -export const appRouter = createTRPCRouter({ - namespace: namespaceRouter, - apiKey: apiKeysRouter, - ingestJob: ingestJobRouter, - document: documentsRouter, - upload: uploadsRouter, - billing: billingRouter, - organization: organizationsRouter, - hosting: hostingRouter, - domain: domainsRouter, - search: searchRouter, - webhook: webhooksRouter, -}); - -// export type definition of API -export type AppRouter = typeof appRouter; -export const createCaller = createCallerFactory(appRouter); diff --git a/apps/web/src/server/api/routers/api-keys.ts b/apps/web/src/server/api/routers/api-keys.ts deleted file mode 100644 index 888a5e09..00000000 --- a/apps/web/src/server/api/routers/api-keys.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { createApiKey, createApiKeySchema } from "@/services/api-key/create"; -import { deleteApiKey } from "@/services/api-key/delete"; -import { listApiKeys } from "@/services/api-key/list"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { Prisma } from "@agentset/db"; - -export const apiKeysRouter = createTRPCRouter({ - getApiKeys: protectedProcedure - .input( - z.object({ - orgId: z.string(), - }), - ) - .query(async ({ ctx, input }) => { - // make sure the user is a member of the org - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.orgId, - }, - select: { - id: true, - role: true, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const apiKeys = await listApiKeys({ organizationId: input.orgId }); - - return apiKeys; - }), - getDefaultApiKey: protectedProcedure - .input(z.object({ orgId: z.string() })) - .query(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.orgId, - }, - select: { id: true }, - }); - - if (!member) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const apiKey = await ctx.db.organizationApiKey.findFirst({ - where: { - organizationId: input.orgId, - label: "Default API Key", - }, - select: { key: true }, - }); - - return apiKey?.key ?? null; - }), - createApiKey: protectedProcedure - .input(createApiKeySchema) - .mutation(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const apiKey = await createApiKey(input); - - return apiKey; - }), - deleteApiKey: protectedProcedure - .input(z.object({ orgId: z.string(), id: z.string() })) - .mutation(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.orgId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - try { - // scoped to the org so a member of one org can't delete another org's key - await deleteApiKey({ id: input.id, organizationId: input.orgId }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2025" - ) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - throw error; - } - - return { success: true }; - }), -}); diff --git a/apps/web/src/server/api/routers/billing.ts b/apps/web/src/server/api/routers/billing.ts deleted file mode 100644 index 2c42eb96..00000000 --- a/apps/web/src/server/api/routers/billing.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { getBaseUrl } from "@/lib/utils"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { stripe } from "@agentset/stripe"; -import { - getStripeEnvironment, - isFreePlan, - isProPlan, - PRO_PLAN_METERED, -} from "@agentset/stripe/plans"; -import { getFirstAndLastDay } from "@agentset/utils"; - -import { createTRPCRouter, protectedProcedure } from "../trpc"; - -const organizationMiddleware = protectedProcedure - .input( - z.object({ - orgId: z.string(), - }), - ) - .use(async ({ ctx, next, input }) => { - const organization = await ctx.db.organization.findUnique({ - where: { - id: input.orgId, - members: { some: { userId: ctx.session.user.id } }, - }, - select: { - id: true, - slug: true, - stripeId: true, - }, - }); - - if (!organization) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Organization not found", - }); - } - - return next({ - ctx: { - ...ctx, - organization, - }, - }); - }); - -export const billingRouter = createTRPCRouter({ - getCurrentPlan: organizationMiddleware.query(async ({ ctx }) => { - const org = await ctx.db.organization.findUnique({ - where: { - id: ctx.organization.id, - }, - select: { - plan: true, - pagesLimit: true, - totalPages: true, - totalDocuments: true, - totalIngestJobs: true, - billingCycleStart: true, - stripeId: true, - }, - }); - - return org!; - }), - getTrackedPages: organizationMiddleware.query(async ({ ctx }) => { - const org = await ctx.db.organization.findUnique({ - where: { id: ctx.organization.id }, - select: { - plan: true, - stripeId: true, - billingCycleStart: true, - }, - }); - - if (!org || isFreePlan(org.plan) || !org.stripeId) { - return { trackedPages: 0 }; - } - - const subscription = ( - await stripe.subscriptions.list({ - customer: org.stripeId, - status: "active", - limit: 1, - }) - ).data[0]; - - if (!subscription) { - return { trackedPages: 0 }; - } - - const item = subscription.items.data[0]; - const currentPeriodStart = - "current_period_start" in subscription - ? (subscription.current_period_start as number) - : item?.current_period_start; - - const currentPeriodEnd = - "current_period_end" in subscription - ? (subscription.current_period_end as number) - : item?.current_period_end; - - if (!currentPeriodStart || !currentPeriodEnd) { - return { trackedPages: 0 }; - } - - try { - const meters = await stripe.billing.meters.list({ status: "active" }); - const meter = meters.data.find( - (m) => m.event_name === PRO_PLAN_METERED.meterName, - ); - - if (!meter) { - return { trackedPages: 0 }; - } - - const summaries = await stripe.billing.meters.listEventSummaries( - meter.id, - { - customer: org.stripeId, - start_time: currentPeriodStart, - end_time: currentPeriodEnd, - }, - ); - - const trackedPages = summaries.data.reduce( - (sum, s) => sum + s.aggregated_value, - 0, - ); - - return { trackedPages }; - } catch (error) { - console.error("Error fetching tracked pages from Stripe:", error); - return { trackedPages: 0 }; - } - }), - upgrade: organizationMiddleware - .input( - z.object({ - plan: z.enum(["free", "pro"]), - period: z.enum(["monthly", "yearly"]), - baseUrl: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const { plan, period, baseUrl } = input; - - const planKey = plan.replace(" ", "+"); - const prices = await stripe.prices.list({ - lookup_keys: [`${planKey}_${period}`], - }); - const priceId = prices.data[0]!.id; - - const activeSubscription = ctx.organization.stripeId - ? await stripe.subscriptions - .list({ - customer: ctx.organization.stripeId, - status: "active", - }) - .then((res) => res.data[0]) - : null; - - // if the user has an active subscription, create billing portal to upgrade - if (ctx.organization.stripeId && activeSubscription) { - const { url } = await stripe.billingPortal.sessions.create({ - customer: ctx.organization.stripeId, - return_url: baseUrl, - flow_data: { - type: "subscription_update", - subscription_update: { - subscription: activeSubscription.id, - }, - }, - }); - - return { url }; - } else { - // For both new users and users with canceled subscriptions - const stripeSession = await stripe.checkout.sessions.create({ - ...(ctx.organization.stripeId - ? { - customer: ctx.organization.stripeId, - // need to pass this or Stripe will throw an error: https://git.new/kX4fi6B - customer_update: { - name: "auto", - address: "auto", - }, - } - : { - customer_email: ctx.session.user.email, - }), - billing_address_collection: "required", - success_url: `${getBaseUrl()}/${ctx.organization.slug}?upgraded=true&plan=${planKey}&period=${period}`, - cancel_url: baseUrl, - line_items: [ - { price: priceId, quantity: 1 }, - ...(isProPlan(plan) - ? [ - { - price: - PRO_PLAN_METERED[period].priceId[getStripeEnvironment()], - }, - ] - : []), - ], - allow_promotion_codes: true, - automatic_tax: { - enabled: true, - }, - tax_id_collection: { - enabled: true, - }, - mode: "subscription", - client_reference_id: ctx.organization.id, - metadata: { - agentsetCustomerId: ctx.session.user.id, - }, - }); - - return { sessionId: stripeSession.id }; - } - }), - invoices: organizationMiddleware.query(async ({ ctx }) => { - if (!ctx.organization.stripeId) { - return []; - } - - try { - const invoices = await stripe.invoices.list({ - customer: ctx.organization.stripeId, - }); - - return invoices.data.map((invoice) => { - return { - id: invoice.id, - total: invoice.amount_paid, - createdAt: new Date(invoice.created * 1000), - description: "Agentset subscription", - pdfUrl: invoice.invoice_pdf, - }; - }); - } catch (error) { - console.log(error); - return []; - } - }), - manage: organizationMiddleware.mutation(async ({ ctx }) => { - if (!ctx.organization.stripeId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No Stripe customer ID", - }); - } - - try { - const { url } = await stripe.billingPortal.sessions.create({ - customer: ctx.organization.stripeId, - return_url: `${getBaseUrl()}/${ctx.organization.slug}/billing`, - }); - return url; - } catch (error: any) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: error?.raw?.message, - }); - } - }), - getPaymentMethods: organizationMiddleware.query(async ({ ctx }) => { - if (!ctx.organization.stripeId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No Stripe customer ID", - }); - } - - try { - const paymentMethods = await stripe.paymentMethods.list({ - customer: ctx.organization.stripeId, - }); - - // reorder to put ACH first - const ach = paymentMethods.data.find( - (method) => method.type === "us_bank_account", - ); - - return [ - ...(ach ? [ach] : []), - ...paymentMethods.data.filter((method) => method.id !== ach?.id), - ]; - } catch (error) { - console.error(error); - return []; - } - }), - addPaymentMethod: organizationMiddleware - .input( - z.object({ - method: z.enum(["card", "us_bank_account"]).optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - if (!ctx.organization.stripeId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No Stripe customer ID", - }); - } - - if (!input.method) { - const { url } = await stripe.billingPortal.sessions.create({ - customer: ctx.organization.stripeId, - return_url: `${getBaseUrl()}/${ctx.organization.slug}/billing`, - flow_data: { - type: "payment_method_update", - }, - }); - - return url; - } - - const { url } = await stripe.checkout.sessions.create({ - mode: "setup", - customer: ctx.organization.stripeId, - payment_method_types: [input.method], - success_url: `${getBaseUrl()}/${ctx.organization.slug}/billing`, - cancel_url: `${getBaseUrl()}/${ctx.organization.slug}/billing`, - }); - - return url; - }), - cancel: organizationMiddleware.mutation(async ({ ctx }) => { - if (!ctx.organization.stripeId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No Stripe customer ID", - }); - } - - try { - const activeSubscription = await stripe.subscriptions - .list({ - customer: ctx.organization.stripeId, - status: "active", - }) - .then((res) => res.data[0]); - - if (!activeSubscription) - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No active subscription", - }); - - const { url } = await stripe.billingPortal.sessions.create({ - customer: ctx.organization.stripeId, - return_url: `${getBaseUrl()}/${ctx.organization.slug}/billing`, - flow_data: { - type: "subscription_cancel", - subscription_cancel: { - subscription: activeSubscription.id, - }, - }, - }); - return url; - } catch (error: any) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: error.raw.message, - }); - } - }), -}); diff --git a/apps/web/src/server/api/routers/documents.ts b/apps/web/src/server/api/routers/documents.ts deleted file mode 100644 index efc1decd..00000000 --- a/apps/web/src/server/api/routers/documents.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { getDocumentsSchema } from "@/schemas/api/document"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { deleteDocument } from "@/services/documents/delete"; -import { getPaginationArgs, paginateResults } from "@/services/pagination"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { DocumentStatus } from "@agentset/db"; -import { presignChunksDownloadUrl, presignGetUrl } from "@agentset/storage"; - -import { getNamespaceByUser } from "../auth"; - -export const documentsRouter = createTRPCRouter({ - all: protectedProcedure - .input( - getDocumentsSchema.extend({ - namespaceId: z.string(), - ingestJobId: z.string().optional(), - }), - ) - .query(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const { where, ...paginationArgs } = getPaginationArgs( - input, - { - orderBy: input.orderBy, - order: input.order, - }, - "doc_", - ); - - const documents = await ctx.db.document.findMany({ - where: { - namespaceId: namespace.id, - ...(input.ingestJobId && { ingestJobId: input.ingestJobId }), - ...(input.statuses && - input.statuses.length > 0 && { status: { in: input.statuses } }), - ...where, - }, - select: { - id: true, - name: true, - totalTokens: true, - totalChunks: true, - totalCharacters: true, - source: true, - totalPages: true, - documentProperties: true, - createdAt: true, - queuedAt: true, - completedAt: true, - failedAt: true, - error: true, - status: true, - }, - ...paginationArgs, - }); - - return paginateResults(input, documents); - }), - delete: protectedProcedure - .input( - z.object({ - documentId: z.string(), - namespaceId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const document = await ctx.db.document.findUnique({ - where: { - id: input.documentId, - namespaceId: namespace.id, - }, - select: { id: true, status: true }, - }); - - if (!document) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - if ( - document.status === DocumentStatus.QUEUED_FOR_DELETE || - document.status === DocumentStatus.DELETING - ) { - throw new TRPCError({ code: "BAD_REQUEST" }); - } - - const updatedDocument = await deleteDocument({ - documentId: input.documentId, - organizationId: namespace.organizationId, - }); - - return updatedDocument; - }), - getChunksDownloadUrl: protectedProcedure - .input( - z.object({ - documentId: z.string(), - namespaceId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const document = await ctx.db.document.findUnique({ - where: { - id: input.documentId, - namespaceId: namespace.id, - }, - select: { id: true, status: true }, - }); - - if (!document) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - if (document.status !== DocumentStatus.COMPLETED) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Chunks are only available for completed documents", - }); - } - - const url = await presignChunksDownloadUrl(namespace.id, document.id); - if (!url) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return { url }; - }), - getFileDownloadUrl: protectedProcedure - .input( - z.object({ - documentId: z.string(), - namespaceId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const document = await ctx.db.document.findUnique({ - where: { - id: input.documentId, - namespaceId: namespace.id, - }, - select: { id: true, name: true, source: true }, - }); - - if (!document) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - if (document.source.type !== "MANAGED_FILE") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "File download is only available for managed files", - }); - } - - const { url } = await presignGetUrl(document.source.key, { - fileName: document.name ?? undefined, - }); - - return { url }; - }), -}); diff --git a/apps/web/src/server/api/routers/domains.ts b/apps/web/src/server/api/routers/domains.ts deleted file mode 100644 index f35fbccb..00000000 --- a/apps/web/src/server/api/routers/domains.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ProtectedProcedureContext } from "@/server/api/trpc"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { addDomain } from "@/services/domains/add"; -import { checkDomainStatus } from "@/services/domains/check-status"; -import { removeDomain } from "@/services/domains/remove"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -const commonInput = z.object({ - namespaceId: z.string(), -}); - -export type { DomainVerificationStatusProps } from "@/schemas/api/hosting"; - -const getHosting = async ( - ctx: ProtectedProcedureContext, - input: z.infer, -) => { - const hosting = await ctx.db.hosting.findFirst({ - where: { - namespace: { - id: input.namespaceId, - organization: { - members: { some: { userId: ctx.session.user.id } }, - }, - }, - }, - }); - - return hosting ?? null; -}; - -const getHostingOrThrow = async ( - ctx: ProtectedProcedureContext, - input: z.infer, -) => { - const hosting = await getHosting(ctx, input); - if (!hosting) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Hosting not found", - }); - } - - return hosting; -}; - -export const domainsRouter = createTRPCRouter({ - add: protectedProcedure - .input(commonInput.extend({ domain: z.string() })) - .mutation(async ({ ctx, input }) => { - const hosting = await getHostingOrThrow(ctx, input); - return addDomain({ hostingId: hosting.id, domain: input.domain }); - }), - checkStatus: protectedProcedure - .input(commonInput) - .query(async ({ ctx, input }) => { - const hosting = await getHostingOrThrow(ctx, input); - return checkDomainStatus({ hostingId: hosting.id }); - }), - remove: protectedProcedure - .input(commonInput) - .mutation(async ({ ctx, input }) => { - const hosting = await getHostingOrThrow(ctx, input); - return removeDomain({ hostingId: hosting.id }); - }), -}); diff --git a/apps/web/src/server/api/routers/hosting.ts b/apps/web/src/server/api/routers/hosting.ts deleted file mode 100644 index 0b005991..00000000 --- a/apps/web/src/server/api/routers/hosting.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { ProtectedProcedureContext } from "@/server/api/trpc"; -import { updateHostingSchema } from "@/schemas/api/hosting"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { deleteHosting } from "@/services/hosting/delete"; -import { enableHosting } from "@/services/hosting/enable"; -import { getHosting } from "@/services/hosting/get"; -import { updateHosting } from "@/services/hosting/update"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -const commonInput = z.object({ namespaceId: z.string() }); - -const verifyNamespaceAccess = async ( - ctx: ProtectedProcedureContext, - namespaceId: string, -) => { - const namespace = await ctx.db.namespace.findFirst({ - where: { - id: namespaceId, - organization: { - members: { some: { userId: ctx.session.user.id } }, - }, - }, - }); - - if (!namespace) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Namespace not found or you don't have access to it", - }); - } - - return namespace; -}; - -// TODO: only allow for pro users -export const hostingRouter = createTRPCRouter({ - get: protectedProcedure.input(commonInput).query(async ({ ctx, input }) => { - await verifyNamespaceAccess(ctx, input.namespaceId); - return getHosting({ namespaceId: input.namespaceId }); - }), - enable: protectedProcedure - .input(commonInput) - .mutation(async ({ ctx, input }) => { - await verifyNamespaceAccess(ctx, input.namespaceId); - - return enableHosting({ - namespaceId: input.namespaceId, - }); - }), - update: protectedProcedure - .input(commonInput.extend(updateHostingSchema.shape)) - .mutation(async ({ ctx, input: { namespaceId, ...input } }) => { - await verifyNamespaceAccess(ctx, namespaceId); - - return updateHosting({ - namespaceId, - input, - }); - }), - delete: protectedProcedure - .input(commonInput) - .mutation(async ({ ctx, input }) => { - await verifyNamespaceAccess(ctx, input.namespaceId); - - await deleteHosting({ namespaceId: input.namespaceId }); - - return { success: true }; - }), -}); diff --git a/apps/web/src/server/api/routers/ingest-jobs.ts b/apps/web/src/server/api/routers/ingest-jobs.ts deleted file mode 100644 index 60f56050..00000000 --- a/apps/web/src/server/api/routers/ingest-jobs.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - createIngestJobSchema, - getIngestionJobsSchema, -} from "@/schemas/api/ingest-job"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { createIngestJob } from "@/services/ingest-jobs/create"; -import { deleteIngestJob } from "@/services/ingest-jobs/delete"; -import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; -import { getPaginationArgs, paginateResults } from "@/services/pagination"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { getNamespaceByUser } from "../auth"; - -export const ingestJobRouter = createTRPCRouter({ - all: protectedProcedure - .input(getIngestionJobsSchema.extend({ namespaceId: z.string() })) - .query(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const { where, ...paginationArgs } = getPaginationArgs( - input, - { - orderBy: input.orderBy, - order: input.order, - }, - "job_", - ); - - const ingestJobs = await ctx.db.ingestJob.findMany({ - where: { - namespaceId: input.namespaceId, - ...(input.statuses && - input.statuses.length > 0 && { - status: { in: input.statuses }, - }), - ...where, - }, - select: { - id: true, - status: true, - name: true, - tenantId: true, - completedAt: true, - failedAt: true, - error: true, - queuedAt: true, - createdAt: true, - }, - ...paginationArgs, - }); - - return paginateResults(input, ingestJobs); - }), - getConfig: protectedProcedure - .input(z.object({ jobId: z.string(), namespaceId: z.string() })) - .query(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const ingestJob = await ctx.db.ingestJob.findUnique({ - where: { - id: input.jobId, - namespaceId: namespace.id, - }, - select: { - config: true, - }, - }); - - if (!ingestJob) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return ingestJob.config; - }), - ingest: protectedProcedure - .input( - createIngestJobSchema.extend({ - namespaceId: z.string(), - }), - ) - .mutation(async ({ ctx, input: { namespaceId, ...input } }) => { - const namespace = await getNamespaceByUser(ctx, { - id: namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const organization = await ctx.db.organization.findUnique({ - where: { id: namespace.organizationId }, - }); - - if (!organization) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return await createIngestJob({ - data: input, - organization, - namespaceId: namespace.id, - }); - }), - delete: protectedProcedure - .input(z.object({ jobId: z.string(), namespaceId: z.string() })) - .mutation(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return await deleteIngestJob({ - jobId: input.jobId, - namespaceId: namespace.id, - organizationId: namespace.organizationId, - }); - }), - reIngest: protectedProcedure - .input(z.object({ jobId: z.string(), namespaceId: z.string() })) - .mutation(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - // the service fetches the org plan in parallel with the job lookup - return await reIngestJob({ - jobId: input.jobId, - namespaceId: namespace.id, - organizationId: namespace.organizationId, - }); - }), -}); diff --git a/apps/web/src/server/api/routers/namespaces.ts b/apps/web/src/server/api/routers/namespaces.ts deleted file mode 100644 index 4d141958..00000000 --- a/apps/web/src/server/api/routers/namespaces.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { ProtectedProcedureContext } from "@/server/api/trpc"; -import { createNamespaceSchema } from "@/schemas/api/namespace"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { createNamespace } from "@/services/namespaces/create"; -import { deleteNamespace } from "@/services/namespaces/delete"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { NamespaceStatus } from "@agentset/db"; -import { getDemoTemplate } from "@agentset/demo"; -import { triggerSeedDemoNamespace } from "@agentset/jobs"; - -const validateIsMember = async ( - ctx: ProtectedProcedureContext, - orgId: string | { slug: string }, - roles?: string[], -) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - ...(typeof orgId === "string" - ? { organizationId: orgId } - : { - organization: { - slug: orgId.slug, - }, - }), - }, - select: { - id: true, - role: true, - organizationId: true, - }, - }); - - if (!member) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if (roles && !roles.includes(member.role)) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return member; -}; - -export const namespaceRouter = createTRPCRouter({ - getOrgNamespaces: protectedProcedure - .input( - z.object({ - slug: z.string(), - }), - ) - .query(async ({ ctx, input }) => { - const member = await validateIsMember(ctx, { slug: input.slug }); - - const namespaces = await ctx.db.namespace.findMany({ - where: { - organizationId: member.organizationId, - status: NamespaceStatus.ACTIVE, - }, - orderBy: { - createdAt: "desc", - }, - }); - - return namespaces; - }), - getNamespaceBySlug: protectedProcedure - .input(z.object({ orgSlug: z.string(), slug: z.string() })) - .query(async ({ ctx, input }) => { - const namespace = await ctx.db.namespace.findFirst({ - where: { - slug: input.slug, - organization: { - slug: input.orgSlug, - members: { some: { userId: ctx.session.user.id } }, - }, - status: NamespaceStatus.ACTIVE, - }, - }); - - return namespace; - }), - getOnboardingStatus: protectedProcedure - .input(z.object({ orgSlug: z.string(), slug: z.string() })) - .query(async ({ ctx, input }) => { - const namespace = await ctx.db.namespace.findFirst({ - where: { - slug: input.slug, - organization: { - slug: input.orgSlug, - members: { some: { userId: ctx.session.user.id } }, - }, - status: NamespaceStatus.ACTIVE, - }, - select: { - totalIngestJobs: true, - totalPlaygroundUsage: true, - organization: { - select: { - apiKeys: { - take: 1, - select: { - id: true, - }, - }, - }, - }, - }, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return { - ingestDocuments: namespace.totalIngestJobs > 0, - playground: namespace.totalPlaygroundUsage > 0, - createApiKey: namespace.organization.apiKeys.length > 0, - }; - }), - checkSlug: protectedProcedure - .input( - z.object({ - orgId: z.string(), - slug: z.string(), - }), - ) - .query(async ({ ctx, input }) => { - const namespace = await ctx.db.namespace.findUnique({ - where: { - organizationId_slug: { - slug: input.slug, - organizationId: input.orgId, - }, - }, - }); - - return !!namespace; - }), - createNamespace: protectedProcedure - .input( - createNamespaceSchema.extend( - z.object({ - orgId: z.string(), - }).shape, - ), - ) - .mutation(async ({ ctx, input: { orgId, ...data } }) => { - await validateIsMember(ctx, orgId, ["admin", "owner"]); - - return await createNamespace({ - organizationId: orgId, - data, - }); - }), - createDemoNamespace: protectedProcedure - .input( - z.object({ - orgId: z.string(), - templateId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - await validateIsMember(ctx, input.orgId, ["admin", "owner"]); - - const template = getDemoTemplate(input.templateId); - if (!template) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid template ID", - }); - } - - const organization = await ctx.db.organization.findUnique({ - where: { id: input.orgId }, - select: { id: true, plan: true }, - }); - if (!organization) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - let slug: string = template.id; - let suffix = 2; - while (true) { - const existing = await ctx.db.namespace.findUnique({ - where: { - organizationId_slug: { - organizationId: input.orgId, - slug, - }, - }, - select: { id: true }, - }); - - if (!existing) break; - - slug = `${template.id}-${suffix}`; - suffix += 1; - } - - const [namespace] = await ctx.db.$transaction([ - ctx.db.namespace.create({ - data: { - name: template.name, - slug, - demoId: template.id, - organizationId: input.orgId, - embeddingConfig: { - provider: "MANAGED_OPENAI", - model: "text-embedding-3-large", - }, - vectorStoreConfig: { - provider: "MANAGED_TURBOPUFFER", - }, - }, - }), - ctx.db.organization.update({ - where: { id: input.orgId }, - data: { - totalNamespaces: { increment: 1 }, - }, - }), - ]); - - await triggerSeedDemoNamespace( - { - namespaceId: namespace.id, - organizationId: organization.id, - templateId: template.id, - }, - organization.plan, - ); - - return namespace; - }), - deleteNamespace: protectedProcedure - .input( - z.object({ - namespaceId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const namespace = await ctx.db.namespace.findFirst({ - where: { - id: input.namespaceId, - organization: { - members: { - some: { - userId: ctx.session.user.id, - role: { - in: ["admin", "owner"], - }, - }, - }, - }, - status: NamespaceStatus.ACTIVE, - }, - select: { - id: true, - organizationId: true, - }, - }); - - if (!namespace) { - throw new TRPCError({ - code: "NOT_FOUND", - message: - "Namespace not found or you don't have permission to delete it", - }); - } - - await deleteNamespace({ namespaceId: namespace.id }); - - return { success: true }; - }), -}); diff --git a/apps/web/src/server/api/routers/organizations.ts b/apps/web/src/server/api/routers/organizations.ts deleted file mode 100644 index 932b49df..00000000 --- a/apps/web/src/server/api/routers/organizations.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { deleteOrganization } from "@/services/organizations/delete"; -import { getOrganizationMembers } from "@/services/organizations/members"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { OrganizationStatus } from "@agentset/db"; - -import { createTRPCRouter, protectedProcedure } from "../trpc"; - -export const organizationsRouter = createTRPCRouter({ - all: protectedProcedure.query(async ({ ctx }) => { - const orgs = await ctx.db.organization.findMany({ - where: { - members: { - some: { - userId: ctx.session.user.id, - }, - }, - status: OrganizationStatus.ACTIVE, - }, - select: { - id: true, - name: true, - slug: true, - plan: true, - logo: true, - namespaces: { - select: { - id: true, - name: true, - slug: true, - }, - }, - }, - orderBy: { - createdAt: "asc", - }, - }); - - return orgs; - }), - getBySlug: protectedProcedure - .input(z.object({ slug: z.string() })) - .query(async ({ ctx, input }) => { - const org = await ctx.db.organization.findUnique({ - where: { - slug: input.slug, - members: { - some: { - userId: ctx.session.user.id, - }, - }, - }, - include: { - members: { - where: { - userId: ctx.session.user.id, - }, - take: 1, - select: { - id: true, - role: true, - }, - }, - }, - }); - - if (!org) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const { members } = org; - const isAdmin = - members[0]?.role === "admin" || members[0]?.role === "owner"; - - return { - ...org, - isAdmin, - isOwner: members[0]?.role === "owner", - currentMemberId: members[0]?.id, - }; - }), - members: protectedProcedure - .input( - z.object({ - organizationId: z.string(), - }), - ) - .query(async ({ ctx, input }) => { - const members = await getOrganizationMembers({ - organizationId: input.organizationId, - userId: ctx.session.user.id, - }); - - return members; - }), - delete: protectedProcedure - .input( - z.object({ - organizationId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const org = await ctx.db.organization.findUnique({ - where: { - id: input.organizationId, - members: { - some: { - userId: ctx.session.user.id, - role: { in: ["admin", "owner"] }, - }, - }, - }, - }); - - if (!org) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to delete this organization", - }); - } - - if (org.status === OrganizationStatus.DELETING) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Organization is already being deleted", - }); - } - - await deleteOrganization({ organizationId: org.id }); - }), -}); diff --git a/apps/web/src/server/api/routers/search.ts b/apps/web/src/server/api/routers/search.ts deleted file mode 100644 index 71518e5a..00000000 --- a/apps/web/src/server/api/routers/search.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { incrementSearchUsage } from "@/lib/api/usage"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { - getNamespaceEmbeddingModel, - getNamespaceVectorStore, - queryVectorStore, -} from "@agentset/engine"; -import { rerankerSchema } from "@agentset/validation"; - -import { getNamespaceByUser } from "../auth"; - -const chunkExplorerInputSchema = z.object({ - namespaceId: z.string(), - query: z.string().min(1), - topK: z.number().min(1).max(100), - rerank: z.boolean(), - rerankModel: rerankerSchema, - rerankLimit: z.number().min(1).max(100), - filter: z.record(z.string(), z.any()).optional(), -}); - -export const searchRouter = createTRPCRouter({ - search: protectedProcedure - .input(chunkExplorerInputSchema) - .query(async ({ ctx, input }) => { - const namespace = await getNamespaceByUser(ctx, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const [embeddingModel, vectorStore] = await Promise.all([ - getNamespaceEmbeddingModel(namespace, "query"), - getNamespaceVectorStore(namespace), - ]); - - const queryResult = await queryVectorStore({ - query: input.query, - topK: input.topK, - filter: input.filter, - includeMetadata: true, - rerank: input.rerank - ? { model: input.rerankModel, limit: input.rerankLimit } - : false, - embeddingModel, - vectorStore, - consistency: "strong", - }); - - // Track search usage - incrementSearchUsage(namespace.id, 1); - - return queryResult.results; - }), -}); diff --git a/apps/web/src/server/api/routers/uploads.ts b/apps/web/src/server/api/routers/uploads.ts deleted file mode 100644 index f0065ca3..00000000 --- a/apps/web/src/server/api/routers/uploads.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { batchUploadSchema, uploadFileSchema } from "@/schemas/api/upload"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { createBatchUpload, createUpload } from "@/services/uploads"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { getNamespaceByUser } from "../auth"; - -export const uploadsRouter = createTRPCRouter({ - getPresignedUrl: protectedProcedure - .input( - uploadFileSchema.extend({ - namespaceId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const ns = await getNamespaceByUser(ctx, { id: input.namespaceId }); - - if (!ns) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Namespace not found", - }); - } - - const result = await createUpload({ - namespaceId: ns.id, - file: { - fileName: input.fileName, - contentType: input.contentType, - fileSize: input.fileSize, - }, - }); - - if (!result.success) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: result.error, - }); - } - - return result.data; - }), - getPresignedUrls: protectedProcedure - .input( - batchUploadSchema.extend({ - namespaceId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const ns = await getNamespaceByUser(ctx, { id: input.namespaceId }); - - if (!ns) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Namespace not found", - }); - } - - const result = await createBatchUpload({ - namespaceId: ns.id, - files: input.files, - }); - - if (!result.success) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: result.error, - }); - } - - return result.data; - }), -}); diff --git a/apps/web/src/server/api/routers/webhooks.ts b/apps/web/src/server/api/routers/webhooks.ts deleted file mode 100644 index 35103a2e..00000000 --- a/apps/web/src/server/api/routers/webhooks.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { samplePayload } from "@/lib/webhook/sample-events/payload"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { createWebhook } from "@/services/webhooks/create"; -import { deleteWebhook } from "@/services/webhooks/delete"; -import { getWebhook } from "@/services/webhooks/get"; -import { listWebhooks } from "@/services/webhooks/list"; -import { requireWebhooksPlan } from "@/services/webhooks/plan"; -import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; -import { toggleWebhook } from "@/services/webhooks/toggle"; -import { updateWebhook } from "@/services/webhooks/update"; -import { TRPCError } from "@trpc/server"; -import { nanoid } from "nanoid"; -import { z } from "zod/v4"; - -import { triggerSendWebhook } from "@agentset/jobs"; -import { getWebhookEvents } from "@agentset/tinybird"; -import { - createWebhookSchema, - updateWebhookSchema, - WEBHOOK_EVENT_ID_PREFIX, - WEBHOOK_TRIGGERS, - webhookPayloadSchema, -} from "@agentset/webhooks"; - -export const webhooksRouter = createTRPCRouter({ - // List all webhooks for an organization - list: protectedProcedure - .input(z.object({ organizationId: z.string() })) - .query(async ({ ctx, input }) => { - // Verify user is member of org - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return listWebhooks({ organizationId: input.organizationId }); - }), - - // Get a single webhook by ID - get: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .query(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const webhook = await getWebhook({ - organizationId: input.organizationId, - webhookId: input.webhookId, - }); - - if (!webhook) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return webhook; - }), - - // Get webhook events from Tinybird - getEvents: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .query(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - // Verify webhook belongs to org - const webhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - select: { id: true }, - }); - - if (!webhook) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const events = await getWebhookEvents({ webhookId: input.webhookId }); - return events.data; - }), - - // Create a new webhook - create: protectedProcedure - .input(createWebhookSchema.extend({ organizationId: z.string() })) - .mutation(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - include: { - organization: { - select: { plan: true }, - }, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - // Check pro plan requirement - requireWebhooksPlan(member.organization.plan); - - return createWebhook({ - name: input.name, - url: input.url, - secret: input.secret, - triggers: input.triggers, - namespaceIds: input.namespaceIds, - organizationId: input.organizationId, - }); - }), - - // Update a webhook - update: protectedProcedure - .input( - updateWebhookSchema.extend({ - organizationId: z.string(), - webhookId: z.string(), - }), - ) - .mutation(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - include: { - organization: { - select: { plan: true }, - }, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - requireWebhooksPlan(member.organization.plan); - - const webhook = await updateWebhook({ - organizationId: input.organizationId, - webhookId: input.webhookId, - name: input.name, - url: input.url, - secret: input.secret, - triggers: input.triggers, - namespaceIds: input.namespaceIds, - }); - - if (!webhook) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return webhook; - }), - - // Delete a webhook - delete: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .mutation(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const webhook = await deleteWebhook({ - organizationId: input.organizationId, - webhookId: input.webhookId, - }); - - if (!webhook) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return { success: true }; - }), - - // Toggle webhook enabled/disabled - toggle: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .mutation(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const updatedWebhook = await toggleWebhook({ - organizationId: input.organizationId, - webhookId: input.webhookId, - }); - - if (!updatedWebhook) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return { disabledAt: updatedWebhook.disabledAt }; - }), - - // Send test webhook event - sendTest: protectedProcedure - .input( - z.object({ - organizationId: z.string(), - webhookId: z.string(), - trigger: z.enum(WEBHOOK_TRIGGERS), - }), - ) - .mutation(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const webhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - select: { - id: true, - url: true, - secret: true, - }, - }); - - if (!webhook) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - const payload = webhookPayloadSchema.parse({ - id: `${WEBHOOK_EVENT_ID_PREFIX}${nanoid(25)}`, - event: input.trigger, - createdAt: new Date().toISOString(), - data: samplePayload[input.trigger], - }); - - await triggerSendWebhook({ - webhookId: webhook.id, - eventId: payload.id, - event: input.trigger, - url: webhook.url, - secret: webhook.secret, - payload, - }); - - return { success: true }; - }), - - // Generate a new secret for a webhook - regenerateSecret: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .mutation(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const updatedWebhook = await regenerateWebhookSecret({ - organizationId: input.organizationId, - webhookId: input.webhookId, - }); - - if (!updatedWebhook) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return { secret: updatedWebhook.secret }; - }), - - // Get namespaces for webhook namespace selector - getNamespaces: protectedProcedure - .input(z.object({ organizationId: z.string() })) - .query(async ({ ctx, input }) => { - const member = await ctx.db.member.findFirst({ - where: { - userId: ctx.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - const namespaces = await ctx.db.namespace.findMany({ - where: { - organizationId: input.organizationId, - status: "ACTIVE", - }, - select: { - id: true, - name: true, - slug: true, - }, - orderBy: { - name: "asc", - }, - }); - - return namespaces; - }), -}); diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts deleted file mode 100644 index 83d8e6b4..00000000 --- a/apps/web/src/server/api/trpc.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: - * 1. You want to modify request context (see Part 1). - * 2. You want to create a new middleware or type of procedure (see Part 3). - * - * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will - * need to use are documented accordingly near the end. - */ -import { AgentsetApiError } from "@/lib/api/errors"; -import { auth } from "@/lib/auth"; -import { initTRPC, TRPCError } from "@trpc/server"; -import superjson from "superjson"; -import { ZodError } from "zod/v4"; - -import { db } from "@agentset/db/client"; - -/** - * 1. CONTEXT - * - * This section defines the "contexts" that are available in the backend API. - * - * These allow you to access things when processing a request, like the database, the session, etc. - * - * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each - * wrap this and provides the required context. - * - * @see https://trpc.io/docs/server/context - */ -export const createTRPCContext = async (opts: { headers: Headers }) => { - const session = await auth.api.getSession({ - headers: opts.headers, - }); - - return { - db, - session, - ...opts, - }; -}; - -/** - * 2. INITIALIZATION - * - * This is where the tRPC API is initialized, connecting the context and transformer. We also parse - * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation - * errors on the backend. - */ -const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: - error.cause instanceof ZodError ? error.cause.flatten() : null, - }, - }; - }, -}); - -/** - * Create a server-side caller. - * - * @see https://trpc.io/docs/server/server-side-calls - */ -export const createCallerFactory = t.createCallerFactory; - -/** - * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) - * - * These are the pieces you use to build your tRPC API. You should import these a lot in the - * "/src/server/api/routers" directory. - */ - -/** - * This is how you create new routers and sub-routers in your tRPC API. - * - * @see https://trpc.io/docs/router - */ -export const createTRPCRouter = t.router; - -/** - * Map AgentsetApiError error codes to TRPC error codes - */ -const errorCodeToTRPCCode = ( - code: string, -): - | "BAD_REQUEST" - | "UNAUTHORIZED" - | "FORBIDDEN" - | "NOT_FOUND" - | "CONFLICT" - | "INTERNAL_SERVER_ERROR" - | "TOO_MANY_REQUESTS" - | "UNPROCESSABLE_CONTENT" => { - switch (code) { - case "bad_request": - return "BAD_REQUEST"; - case "unauthorized": - return "UNAUTHORIZED"; - case "forbidden": - case "exceeded_limit": - return "FORBIDDEN"; - case "not_found": - return "NOT_FOUND"; - case "conflict": - case "invite_pending": - return "CONFLICT"; - case "rate_limit_exceeded": - return "TOO_MANY_REQUESTS"; - case "unprocessable_entity": - return "UNPROCESSABLE_CONTENT"; - default: - return "INTERNAL_SERVER_ERROR"; - } -}; - -/** - * Middleware for timing procedure execution and adding an artificial delay in development. - * - * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating - * network latency that would occur in production but not in local development. - */ -const timingMiddleware = t.middleware(async ({ next, path }) => { - const start = Date.now(); - - try { - const result = await next(); - - const end = Date.now(); - console.log(`[TRPC] ${path} took ${end - start}ms to execute`); - - return result; - } catch (error) { - // Convert AgentsetApiError to TRPCError - if (error instanceof AgentsetApiError) { - throw new TRPCError({ - code: errorCodeToTRPCCode(error.code), - message: error.message, - }); - } - throw error; - } -}); - -/** - * Public (unauthenticated) procedure - * - * This is the base piece you use to build new queries and mutations on your tRPC API. It does not - * guarantee that a user querying is authorized, but you can still access user session data if they - * are logged in. - */ -export const publicProcedure = t.procedure.use(timingMiddleware); - -/** - * Protected (authenticated) procedure - * - * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies - * the session is valid and guarantees `ctx.session.user` is not null. - * - * @see https://trpc.io/docs/procedures - */ -export const protectedProcedure = t.procedure - .use(timingMiddleware) - .use(({ ctx, next }) => { - if (!ctx.session) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - return next({ - ctx: { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - session: ctx.session as NonNullable, - }, - }); - }); - -export type ProcedureContext = Awaited>; - -export type ProtectedProcedureContext = ProcedureContext & { - session: NonNullable; -}; diff --git a/apps/web/src/server/orpc/public/chat.ts b/apps/web/src/server/orpc/public/chat.ts index 5da5444f..cd45e632 100644 --- a/apps/web/src/server/orpc/public/chat.ts +++ b/apps/web/src/server/orpc/public/chat.ts @@ -11,8 +11,8 @@ import { successSchema, } from "@/server/orpc/base"; import { toModelMessages } from "@/services/chat"; -import { type } from "@orpc/server"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { z } from "zod/v4"; import type { QueryVectorStoreResult } from "@agentset/engine"; @@ -72,22 +72,23 @@ console.log(result.message.content); .input(chatSchema.extend({ namespaceId: namespaceIdPathSchema })) .use(requireNamespace, (input) => input.namespaceId) .output( - type< - | { - status?: number; - headers?: Record; - body: ReadableStream; - } - | { - body: { + // shaped for the OpenAPI generator's detailed-output check; `body` is an + // always-pass custom schema so both the SSE ReadableStream and the JSON + // envelope flow through untouched (real docs live in the spec override) + z.object({ + status: z.literal(200).optional(), + headers: z.record(z.string(), z.string()).optional(), + body: z.custom< + | ReadableStream + | { success: true; data: { message: { role: "assistant"; content: string }; sources?: QueryVectorStoreResult["results"]; }; - }; - } - >(), + } + >(() => true), + }), ) .handler(async ({ context, input }) => { // TODO: set hard limits to prevent abuse diff --git a/apps/web/src/trpc/query-client.ts b/apps/web/src/trpc/query-client.ts deleted file mode 100644 index 7436c351..00000000 --- a/apps/web/src/trpc/query-client.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - defaultShouldDehydrateQuery, - QueryClient, -} from "@tanstack/react-query"; -import SuperJSON from "superjson"; - -export const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - // With SSR, we usually want to set some default staleTime - // above 0 to avoid refetching immediately on the client - staleTime: 30 * 1000, - }, - dehydrate: { - serializeData: SuperJSON.serialize, - shouldDehydrateQuery: (query) => - defaultShouldDehydrateQuery(query) || - query.state.status === "pending", - shouldRedactErrors: () => { - // We should not catch Next.js server errors - // as that's how Next.js detects dynamic pages - // so we cannot redact them. - // Next.js also automatically redacts errors for us - // with better digests. - return false; - }, - }, - hydrate: { - deserializeData: SuperJSON.deserialize, - }, - }, - }); diff --git a/apps/web/src/trpc/react.tsx b/apps/web/src/trpc/react.tsx deleted file mode 100644 index 7937a6d9..00000000 --- a/apps/web/src/trpc/react.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import type { AppRouter } from "@/server/api/root"; -import type { QueryClient } from "@tanstack/react-query"; -import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; -import { env } from "@/env"; -import { getBaseUrl } from "@/lib/utils"; -import { QueryClientProvider } from "@tanstack/react-query"; -import { - createTRPCClient, - httpBatchStreamLink, - loggerLink, -} from "@trpc/client"; -import { createTRPCContext } from "@trpc/tanstack-react-query"; -import SuperJSON from "superjson"; - -import { createQueryClient } from "./query-client"; - -let clientQueryClientSingleton: QueryClient | undefined = undefined; -const getQueryClient = () => { - if (typeof window === "undefined") { - // Server: always make a new query client - return createQueryClient(); - } - // Browser: use singleton pattern to keep the same query client - return (clientQueryClientSingleton ??= createQueryClient()); -}; - -export const { useTRPC, TRPCProvider } = createTRPCContext(); - -export const trpcClient = createTRPCClient({ - links: [ - loggerLink({ - enabled: (op) => - env.NODE_ENV === "development" || - (op.direction === "down" && op.result instanceof Error), - }), - httpBatchStreamLink({ - transformer: SuperJSON, - url: getBaseUrl() + "/api/trpc", - headers: () => { - const headers = new Headers(); - headers.set("x-trpc-source", "nextjs-react"); - return headers; - }, - }), - ], -}); - -/** - * Inference helper for inputs. - * - * @example type HelloInput = RouterInputs['example']['hello'] - */ -export type RouterInputs = inferRouterInputs; - -/** - * Inference helper for outputs. - * - * @example type HelloOutput = RouterOutputs['example']['hello'] - */ -export type RouterOutputs = inferRouterOutputs; - -export function TRPCReactProvider(props: { children: React.ReactNode }) { - const queryClient = getQueryClient(); - - return ( - - - {props.children} - - - ); -} diff --git a/apps/web/src/trpc/server.tsx b/apps/web/src/trpc/server.tsx deleted file mode 100644 index f3f05c9c..00000000 --- a/apps/web/src/trpc/server.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import "server-only"; - -import type { AppRouter } from "@/server/api/root"; -import type { TRPCQueryOptions } from "@trpc/tanstack-react-query"; -import { cache } from "react"; -import { headers } from "next/headers"; -import { appRouter, createCaller } from "@/server/api/root"; -import { createTRPCContext } from "@/server/api/trpc"; -import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; - -import { createQueryClient } from "./query-client"; - -/** - * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when - * handling a tRPC call from a React Server Component. - */ -const createContext = cache(async () => { - const heads = new Headers(await headers()); - heads.set("x-trpc-source", "rsc"); - - return createTRPCContext({ - headers: heads, - }); -}); - -const getQueryClient = cache(createQueryClient); -export const trpc = createTRPCOptionsProxy({ - router: appRouter, - ctx: createContext, - queryClient: getQueryClient, -}); - -export const trpcApi = createCaller(createContext); - -export function HydrateClient(props: { children: React.ReactNode }) { - const queryClient = getQueryClient(); - return ( - - {props.children} - - ); -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function prefetch>>( - queryOptions: T, -) { - const queryClient = getQueryClient(); - if (queryOptions.queryKey[1]?.type === "infinite") { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - void queryClient.prefetchInfiniteQuery(queryOptions as any); - } else { - void queryClient.prefetchQuery(queryOptions); - } -} diff --git a/bun.lock b/bun.lock index a0791b98..96f50d65 100644 --- a/bun.lock +++ b/bun.lock @@ -34,11 +34,14 @@ "@hookform/resolvers": "^5.2.2", "@modelcontextprotocol/sdk": "1.26.0", "@number-flow/react": "^0.5.10", + "@orpc/client": "1.14.6", + "@orpc/json-schema": "1.14.6", + "@orpc/openapi": "1.14.6", + "@orpc/server": "1.14.6", + "@orpc/tanstack-query": "1.14.6", + "@orpc/zod": "1.14.6", "@t3-oss/env-nextjs": "catalog:", "@tanstack/react-query": "catalog:", - "@trpc/client": "catalog:", - "@trpc/server": "catalog:", - "@trpc/tanstack-react-query": "catalog:", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.34.9", "@vercel/functions": "^3.3.6", @@ -59,7 +62,6 @@ "resend": "^6.7.0", "server-only": "^0.0.1", "shadcn": "^3.8.4", - "superjson": "^2.2.6", "tw-animate-css": "^1.4.0", "ua-parser-js": "^2.0.8", "usehooks-ts": "^3.1.1", @@ -956,6 +958,38 @@ "@origin-space/image-cropper": ["@origin-space/image-cropper@0.1.9", "", { "peerDependencies": { "react": "19.2.1", "react-dom": "19.2.1" } }, "sha512-Ebl57I6beh7uKjSv8+r3VObEoOXIXhx4SicDUdxa9U7NVY18SzEGfqPDuhNyXuFIB5G/S8nYUToNlIRp9wwB9Q=="], + "@orpc/client": ["@orpc/client@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6", "@orpc/standard-server-fetch": "1.14.6", "@orpc/standard-server-peer": "1.14.6" } }, "sha512-Y03NcTtmEJdxcqkKBkdGxqe1IHVpD9IorshG4PaTnz9dQIW+RYI8anRo7o0IlbBlzBICN+Ubo1rnw6bpkhagCQ=="], + + "@orpc/contract": ["@orpc/contract@1.14.6", "", { "dependencies": { "@orpc/client": "1.14.6", "@orpc/shared": "1.14.6", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-o4i2ciYWtALidF969S8yj/VFk7ZUmHHKaYGRVitX5K2uMaSB8lJM6TMyBKzRC5Clo99VPjCb9mcl6jMLEAgmKw=="], + + "@orpc/interop": ["@orpc/interop@1.14.6", "", {}, "sha512-ZjNaHH9754uUkC98DHfm9HaGnniFnFRLdXXBWL2u+o1TVzEbiNX8dA5+oChxoSTUD3GiwpjLjAuTWXvrbhm4fA=="], + + "@orpc/json-schema": ["@orpc/json-schema@1.14.6", "", { "dependencies": { "@orpc/contract": "1.14.6", "@orpc/interop": "1.14.6", "@orpc/openapi": "1.14.6", "@orpc/server": "1.14.6", "@orpc/shared": "1.14.6", "json-schema-typed": "^8.0.2" } }, "sha512-p79GOJ88DpOTsOaAQ8Ef/2J5455Yh2qp3KjY9q6858RozHuPSXHu7MSgE/bilkzbiuGNoen3frw7GSiKfYpKSA=="], + + "@orpc/openapi": ["@orpc/openapi@1.14.6", "", { "dependencies": { "@orpc/client": "1.14.6", "@orpc/contract": "1.14.6", "@orpc/interop": "1.14.6", "@orpc/openapi-client": "1.14.6", "@orpc/server": "1.14.6", "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-MdQ0KKLdvqZlmpUgw/6W/6dOfwo1GdtW2kE0VL/AJxqaeM6i3sygtIGAzEr6/CfhIJnfGHqo1jMzdlELQjCqYg=="], + + "@orpc/openapi-client": ["@orpc/openapi-client@1.14.6", "", { "dependencies": { "@orpc/client": "1.14.6", "@orpc/contract": "1.14.6", "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6" } }, "sha512-Z0rdO+oVJTASat3fIQmLEjKnqRsjg4QsjRFwFamZ5W+Y076I3Lbp8XhVcMoHbKyfgm/TKnZu3y41b4NFfPBYiA=="], + + "@orpc/server": ["@orpc/server@1.14.6", "", { "dependencies": { "@orpc/client": "1.14.6", "@orpc/contract": "1.14.6", "@orpc/interop": "1.14.6", "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6", "@orpc/standard-server-aws-lambda": "1.14.6", "@orpc/standard-server-fastify": "1.14.6", "@orpc/standard-server-fetch": "1.14.6", "@orpc/standard-server-node": "1.14.6", "@orpc/standard-server-peer": "1.14.6", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-aHn8dW2clr/fRZzMLbRsZ1Pe4AYxg7YmM3tSZSAkPjn5/3UaHnpuwWeY7LYPok23CGYUmjidTmJQjm0zm+vgoA=="], + + "@orpc/shared": ["@orpc/shared@1.14.6", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-P2W+DdrUq18kUiF7nIw5wDOA0SR41mM/NsVKDVRsdyhdHk9V9KDuW1JRymyMl+7Wo5SDeSr1Rm/VjA5v08+PHw=="], + + "@orpc/standard-server": ["@orpc/standard-server@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6" } }, "sha512-75Oh4rAZb8K7P46d6v7R2JhjqjLLEo7Qs4+ABdF+f0m0uKM1oaykJWy7leqTJ+WaYm+uDbsZl/3nyWie9aeJTg=="], + + "@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6", "@orpc/standard-server-fetch": "1.14.6", "@orpc/standard-server-node": "1.14.6" } }, "sha512-8FQX0uBQ2OKkIrF0u5qpd9EH2uqaM1n37NJSw9bvQqLnqoSfzk0xzVkP5s+c8N0Gec6M9mnrLvB7aqQSlpPukw=="], + + "@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6", "@orpc/standard-server-node": "1.14.6" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-cwS7jRfc+yM8bFyEFVzVcXfgYLfy96KpdPBPUlIQLpInwYvMxePpENM3rEAJ3MHOfMHx1PT5LidrlbR7Npb1Cg=="], + + "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6" } }, "sha512-XnwEnHaKMMBoilK0QVwgMsUvDbCXyz6CDoLgMN51v+p3VSnwjIkrMdOwbc/sj43z6T4irQDu9Zm8T1pxxh1L8w=="], + + "@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6", "@orpc/standard-server-fetch": "1.14.6" } }, "sha512-CmW/EPu1dxoppYMRmsK+F3Z22wMx74RLUtjRokXyRKu4XEX/Ja6nwD+xauQcAMiAQYkp39aCMofAW7BvA46Qcg=="], + + "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6" } }, "sha512-jwVGc5yRA5hk1F/W4yw45P2H4fbu4Tfsk62XijJBXRvtlSNKvvPAuwAd6jFv/fxnq2SH/uskgi91Ja9wgfnYDQ=="], + + "@orpc/tanstack-query": ["@orpc/tanstack-query@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6" }, "peerDependencies": { "@orpc/client": "1.14.6", "@tanstack/query-core": ">=5.80.2" } }, "sha512-uCu6iTT0MDyk+ihba65N2CT56XYMny2N389l8pC+NIOQ7dG46RF98MhaQEvFUQo3atZb6pkKNWkg7BaA1Z9o7g=="], + + "@orpc/zod": ["@orpc/zod@1.14.6", "", { "dependencies": { "@orpc/json-schema": "1.14.6", "@orpc/openapi": "1.14.6", "@orpc/shared": "1.14.6", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.14.6", "@orpc/server": "1.14.6", "zod": ">=3.25.0" } }, "sha512-skGc5Qj6BLy3ir+VgYZZBJFlxDVAYoJPVymVPEOLawEVSf2mje0/oMPiaQJE4+CyVRSIXUHG8MoQnW0X1OoQ5w=="], + "@pinecone-database/pinecone": ["@pinecone-database/pinecone@5.1.1", "", {}, "sha512-2JwnY28qb2AQi3fLlBfiXhV6e4ZEF0/0HNLdoFWoSPcty6Eu7+354M8mo/Hj0Hi7y3TCDkSCXq2dgmbcDSmCFQ=="], "@polka/url": ["@polka/url@0.5.0", "", {}, "sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw=="], @@ -1442,12 +1476,6 @@ "@trigger.dev/sdk": ["@trigger.dev/sdk@4.3.0", "", { "dependencies": { "@opentelemetry/api": "1.9.0", "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/core": "4.3.0", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", "evt": "^2.4.13", "slug": "^6.0.0", "ulid": "^2.3.0", "uncrypto": "^0.1.3", "uuid": "^9.0.0", "ws": "^8.11.0" }, "peerDependencies": { "ai": "^4.2.0 || ^5.0.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai"] }, "sha512-54wyCtXaumNfv1ou8PMGs7gb5+wNz94RjwznTWLoBAMhMmPsiqSTa6qxwjjWbpxAb/FWvasUnfbHxOmio2xdMg=="], - "@trpc/client": ["@trpc/client@11.8.1", "", { "peerDependencies": { "@trpc/server": "11.8.1", "typescript": ">=5.7.2" } }, "sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q=="], - - "@trpc/server": ["@trpc/server@11.8.1", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA=="], - - "@trpc/tanstack-react-query": ["@trpc/tanstack-react-query@11.8.1", "", { "peerDependencies": { "@tanstack/react-query": "^5.80.3", "@trpc/client": "11.8.1", "@trpc/server": "11.8.1", "react": ">=18.2.0", "react-dom": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-0gBbRmRuU9/IVZgNFJuzY7k6ktnkmqjyP78PtcUBO7EgPlBu1y2MA2TdI5GT/VtNNOOhzkVeQpJqtt+0xQ8TOQ=="], - "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@turbopuffer/turbopuffer": ["@turbopuffer/turbopuffer@1.10.0", "", { "dependencies": { "pako": "^2.1.0", "undici": "^7.10.0" } }, "sha512-4ma/BR4/VlAUs7K+a9m6dJvcOoPE41XFt/fJcQphQ94mB2e/xgAGih/zMRRIZ3/5OwTlv9haaSzxZDcxVWqAEg=="], @@ -1754,11 +1782,11 @@ "convict": ["convict@6.2.4", "", { "dependencies": { "lodash.clonedeep": "4.5.0", "yargs-parser": "20.2.9" } }, "sha512-qN60BAwdMVdofckX7AlohVJ2x9UvjTNoKVXCL2LxFk1l7757EJqf1nySdMkPQer0bt8kQ5lQiyZ9/2NvrFBuwQ=="], - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + "copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "4.1.16" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="], "core-js": ["core-js@3.45.1", "", {}, "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg=="], @@ -2300,7 +2328,7 @@ "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "1.0.4", "get-intrinsic": "1.3.0" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], - "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], @@ -2638,6 +2666,8 @@ "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "5.2.1", "define-lazy-prop": "3.0.0", "is-inside-container": "1.0.0", "wsl-utils": "0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "0.1.4", "fast-levenshtein": "2.0.6", "levn": "0.4.1", "prelude-ls": "1.2.1", "type-check": "0.4.0", "word-wrap": "1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], @@ -2786,6 +2816,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -3040,7 +3072,7 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": "19.2.1" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], + "superjson": ["superjson@2.2.2", "", { "dependencies": { "copy-anything": "3.0.5" } }, "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q=="], "supports-color": ["supports-color@10.0.0", "", {}, "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ=="], @@ -3140,7 +3172,7 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@5.3.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg=="], + "type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "1.0.5", "media-typer": "1.1.0", "mime-types": "3.0.1" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -3266,6 +3298,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "wildcard-match": ["wildcard-match@5.1.4", "", {}, "sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "6.2.1", "string-width": "5.1.2", "strip-ansi": "7.1.0" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], @@ -3542,6 +3576,10 @@ "@origin-space/image-cropper/react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], + "@orpc/contract/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@orpc/zod/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "@prisma/config/c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "4.0.3", "confbox": "0.2.2", "defu": "6.1.4", "dotenv": "16.6.1", "exsolve": "1.0.7", "giget": "2.0.0", "jiti": "2.6.1", "ohash": "2.0.11", "pathe": "2.0.3", "perfect-debounce": "1.0.0", "pkg-types": "2.3.0", "rc9": "2.1.2" }, "optionalDependencies": { "magicast": "0.3.5" } }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], @@ -3748,8 +3786,6 @@ "@trigger.dev/core/socket.io": ["socket.io@4.7.4", "", { "dependencies": { "accepts": "1.3.8", "base64id": "2.0.0", "cors": "2.8.5", "debug": "4.3.7", "engine.io": "6.5.5", "socket.io-adapter": "2.5.5", "socket.io-parser": "4.2.4" } }, "sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw=="], - "@trigger.dev/core/superjson": ["superjson@2.2.2", "", { "dependencies": { "copy-anything": "3.0.5" } }, "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q=="], - "@trigger.dev/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@trigger.dev/core/zod-error": ["zod-error@1.5.0", "", { "dependencies": { "zod": "3.25.76" } }, "sha512-zzopKZ/skI9iXpqCEPj+iLCKl9b88E43ehcU+sbRoHuwGd9F1IDVGQ70TyO6kmfiRL1g4IXkjsXK+g1gLYl4WQ=="], @@ -3878,12 +3914,16 @@ "cosmiconfig/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "dot-prop/type-fest": ["type-fest@5.3.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg=="], + "eciesjs/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], "eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "engine.io/@types/node": ["@types/node@22.18.4", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw=="], + "engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "2.1.3" }, "optionalDependencies": { "supports-color": "10.0.0" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "engine.io/ws": ["ws@8.17.1", "", {}, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], @@ -3930,6 +3970,8 @@ "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.1", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3972,7 +4014,7 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "msw/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "msw/type-fest": ["type-fest@5.3.1", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -4460,8 +4502,6 @@ "@trigger.dev/core/socket.io/engine.io": ["engine.io@6.5.5", "", { "dependencies": { "@types/cookie": "0.4.1", "@types/cors": "2.8.17", "@types/node": "22.18.4", "accepts": "1.3.8", "base64id": "2.0.0", "cookie": "0.4.2", "cors": "2.8.5", "debug": "4.3.7", "engine.io-parser": "5.2.3", "ws": "8.17.1" } }, "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA=="], - "@trigger.dev/core/superjson/copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "4.1.16" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="], - "@ts-morph/common/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@types/node-fetch/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -4852,8 +4892,6 @@ "@trigger.dev/core/socket.io/engine.io/ws": ["ws@8.17.1", "", {}, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], - "@trigger.dev/core/superjson/copy-anything/is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], - "@types/node-fetch/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@typescript-eslint/parser/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -4892,6 +4930,8 @@ "trigger.dev/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "3.1.2", "content-type": "1.0.5", "debug": "4.4.1", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "on-finished": "2.4.1", "qs": "6.14.0", "raw-body": "3.0.0", "type-is": "2.0.1" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + "trigger.dev/@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "trigger.dev/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], From 4f2d286876d324101ceb5db54e9fd24274f2bba1 Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Sat, 4 Jul 2026 12:15:52 +0200 Subject: [PATCH 06/10] Generate /openapi.json from the oRPC router with canonical spec parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildOpenApiDocument() (OpenAPIGenerator + ZodToJsonSchemaConverter with compat interceptors + deterministic post-processing) reproduces the legacy zod-openapi document exactly — canonical JSON of old and new specs is byte-identical across all 39 operations and 82 components, so the Speakeasy-generated SDK and Mintlify docs are unaffected. The hand-written spec tree (src/openapi, 58 files) is deleted; shared path/header param schemas move to schemas/api/params.ts and zod-openapi is dropped from the app. Also fixes a handful of new lint errors from the migration waves. --- apps/web/package.json | 1 - apps/web/src/app/openapi.json/route.ts | 4 +- apps/web/src/lib/api/errors.ts | 24 +- apps/web/src/lib/api/tenant.ts | 2 +- apps/web/src/openapi/index.ts | 57 -- apps/web/src/openapi/responses.ts | 66 -- .../src/openapi/v1/api-keys/create-api-key.ts | 44 - .../src/openapi/v1/api-keys/delete-api-key.ts | 40 - apps/web/src/openapi/v1/api-keys/index.ts | 15 - .../src/openapi/v1/api-keys/list-api-keys.ts | 34 - apps/web/src/openapi/v1/chat.ts | 54 - apps/web/src/openapi/v1/code-samples.ts | 28 - .../openapi/v1/documents/delete-document.ts | 36 - .../v1/documents/get-chunks-download-url.ts | 33 - .../src/openapi/v1/documents/get-document.ts | 35 - .../v1/documents/get-file-download-url.ts | 33 - apps/web/src/openapi/v1/documents/index.ts | 23 - .../openapi/v1/documents/list-documents.ts | 51 - apps/web/src/openapi/v1/hosting/add-domain.ts | 41 - .../openapi/v1/hosting/check-domain-status.ts | 34 - .../src/openapi/v1/hosting/delete-hosting.ts | 28 - .../src/openapi/v1/hosting/enable-hosting.ts | 33 - .../web/src/openapi/v1/hosting/get-hosting.ts | 33 - apps/web/src/openapi/v1/hosting/index.ts | 23 - .../src/openapi/v1/hosting/remove-domain.ts | 28 - .../src/openapi/v1/hosting/update-hosting.ts | 45 - apps/web/src/openapi/v1/index.ts | 27 - .../src/openapi/v1/ingest-jobs/create-job.ts | 53 - .../src/openapi/v1/ingest-jobs/delete-job.ts | 37 - .../web/src/openapi/v1/ingest-jobs/get-job.ts | 36 - apps/web/src/openapi/v1/ingest-jobs/index.ts | 21 - .../src/openapi/v1/ingest-jobs/list-jobs.ts | 55 - .../openapi/v1/ingest-jobs/reingest-job.ts | 37 - .../openapi/v1/namespaces/create-namespace.ts | 47 - .../openapi/v1/namespaces/delete-namespace.ts | 30 - .../openapi/v1/namespaces/get-namespace.ts | 34 - apps/web/src/openapi/v1/namespaces/index.ts | 19 - .../openapi/v1/namespaces/list-namespaces.ts | 34 - .../openapi/v1/namespaces/update-namespace.ts | 47 - .../v1/organization/get-organization.ts | 33 - apps/web/src/openapi/v1/organization/index.ts | 15 - .../openapi/v1/organization/list-members.ts | 33 - .../v1/organization/update-organization.ts | 44 - apps/web/src/openapi/v1/search.ts | 55 - .../openapi/v1/uploads/create-batch-upload.ts | 81 -- .../src/openapi/v1/uploads/create-upload.ts | 60 -- apps/web/src/openapi/v1/uploads/index.ts | 13 - apps/web/src/openapi/v1/warm-up.ts | 46 - .../src/openapi/v1/webhooks/create-webhook.ts | 45 - .../src/openapi/v1/webhooks/delete-webhook.ts | 29 - .../src/openapi/v1/webhooks/get-webhook.ts | 35 - apps/web/src/openapi/v1/webhooks/index.ts | 23 - .../src/openapi/v1/webhooks/list-webhooks.ts | 34 - .../v1/webhooks/regenerate-webhook-secret.ts | 36 - .../src/openapi/v1/webhooks/update-webhook.ts | 45 - apps/web/src/openapi/v1/webhooks/utils.ts | 11 - .../v1/utils.ts => schemas/api/params.ts} | 32 +- apps/web/src/server/orpc/base.ts | 8 +- apps/web/src/server/orpc/internal/billing.ts | 4 + apps/web/src/server/orpc/public/chat.ts | 8 +- apps/web/src/server/orpc/public/documents.ts | 2 +- apps/web/src/server/orpc/public/hosting.ts | 2 +- .../web/src/server/orpc/public/ingest-jobs.ts | 34 +- apps/web/src/server/orpc/public/namespaces.ts | 4 +- apps/web/src/server/orpc/public/search.ts | 8 +- apps/web/src/server/orpc/public/uploads.ts | 2 +- apps/web/src/server/orpc/public/warm-up.ts | 2 +- apps/web/src/server/orpc/spec.ts | 954 ++++++++++++++++++ bun.lock | 3 - 69 files changed, 1019 insertions(+), 2004 deletions(-) delete mode 100644 apps/web/src/openapi/index.ts delete mode 100644 apps/web/src/openapi/responses.ts delete mode 100644 apps/web/src/openapi/v1/api-keys/create-api-key.ts delete mode 100644 apps/web/src/openapi/v1/api-keys/delete-api-key.ts delete mode 100644 apps/web/src/openapi/v1/api-keys/index.ts delete mode 100644 apps/web/src/openapi/v1/api-keys/list-api-keys.ts delete mode 100644 apps/web/src/openapi/v1/chat.ts delete mode 100644 apps/web/src/openapi/v1/code-samples.ts delete mode 100644 apps/web/src/openapi/v1/documents/delete-document.ts delete mode 100644 apps/web/src/openapi/v1/documents/get-chunks-download-url.ts delete mode 100644 apps/web/src/openapi/v1/documents/get-document.ts delete mode 100644 apps/web/src/openapi/v1/documents/get-file-download-url.ts delete mode 100644 apps/web/src/openapi/v1/documents/index.ts delete mode 100644 apps/web/src/openapi/v1/documents/list-documents.ts delete mode 100644 apps/web/src/openapi/v1/hosting/add-domain.ts delete mode 100644 apps/web/src/openapi/v1/hosting/check-domain-status.ts delete mode 100644 apps/web/src/openapi/v1/hosting/delete-hosting.ts delete mode 100644 apps/web/src/openapi/v1/hosting/enable-hosting.ts delete mode 100644 apps/web/src/openapi/v1/hosting/get-hosting.ts delete mode 100644 apps/web/src/openapi/v1/hosting/index.ts delete mode 100644 apps/web/src/openapi/v1/hosting/remove-domain.ts delete mode 100644 apps/web/src/openapi/v1/hosting/update-hosting.ts delete mode 100644 apps/web/src/openapi/v1/index.ts delete mode 100644 apps/web/src/openapi/v1/ingest-jobs/create-job.ts delete mode 100644 apps/web/src/openapi/v1/ingest-jobs/delete-job.ts delete mode 100644 apps/web/src/openapi/v1/ingest-jobs/get-job.ts delete mode 100644 apps/web/src/openapi/v1/ingest-jobs/index.ts delete mode 100644 apps/web/src/openapi/v1/ingest-jobs/list-jobs.ts delete mode 100644 apps/web/src/openapi/v1/ingest-jobs/reingest-job.ts delete mode 100644 apps/web/src/openapi/v1/namespaces/create-namespace.ts delete mode 100644 apps/web/src/openapi/v1/namespaces/delete-namespace.ts delete mode 100644 apps/web/src/openapi/v1/namespaces/get-namespace.ts delete mode 100644 apps/web/src/openapi/v1/namespaces/index.ts delete mode 100644 apps/web/src/openapi/v1/namespaces/list-namespaces.ts delete mode 100644 apps/web/src/openapi/v1/namespaces/update-namespace.ts delete mode 100644 apps/web/src/openapi/v1/organization/get-organization.ts delete mode 100644 apps/web/src/openapi/v1/organization/index.ts delete mode 100644 apps/web/src/openapi/v1/organization/list-members.ts delete mode 100644 apps/web/src/openapi/v1/organization/update-organization.ts delete mode 100644 apps/web/src/openapi/v1/search.ts delete mode 100644 apps/web/src/openapi/v1/uploads/create-batch-upload.ts delete mode 100644 apps/web/src/openapi/v1/uploads/create-upload.ts delete mode 100644 apps/web/src/openapi/v1/uploads/index.ts delete mode 100644 apps/web/src/openapi/v1/warm-up.ts delete mode 100644 apps/web/src/openapi/v1/webhooks/create-webhook.ts delete mode 100644 apps/web/src/openapi/v1/webhooks/delete-webhook.ts delete mode 100644 apps/web/src/openapi/v1/webhooks/get-webhook.ts delete mode 100644 apps/web/src/openapi/v1/webhooks/index.ts delete mode 100644 apps/web/src/openapi/v1/webhooks/list-webhooks.ts delete mode 100644 apps/web/src/openapi/v1/webhooks/regenerate-webhook-secret.ts delete mode 100644 apps/web/src/openapi/v1/webhooks/update-webhook.ts delete mode 100644 apps/web/src/openapi/v1/webhooks/utils.ts rename apps/web/src/{openapi/v1/utils.ts => schemas/api/params.ts} (64%) create mode 100644 apps/web/src/server/orpc/spec.ts diff --git a/apps/web/package.json b/apps/web/package.json index c7936437..6a395d8c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -70,7 +70,6 @@ "virtua": "^0.48.3", "zod": "catalog:", "zod-error": "catalog:", - "zod-openapi": "catalog:", "zustand": "^5.0.10" }, "devDependencies": { diff --git a/apps/web/src/app/openapi.json/route.ts b/apps/web/src/app/openapi.json/route.ts index 8f2100b4..45fa06a5 100644 --- a/apps/web/src/app/openapi.json/route.ts +++ b/apps/web/src/app/openapi.json/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; -import { createOpenApiDocument } from "@/openapi"; +import { buildOpenApiDocument } from "@/server/orpc/spec"; export const dynamic = "force-static"; export const GET = async () => { - const openapiDocument = createOpenApiDocument(); + const openapiDocument = await buildOpenApiDocument(); return NextResponse.json(openapiDocument); }; diff --git a/apps/web/src/lib/api/errors.ts b/apps/web/src/lib/api/errors.ts index af63f53e..b11888ef 100644 --- a/apps/web/src/lib/api/errors.ts +++ b/apps/web/src/lib/api/errors.ts @@ -1,4 +1,3 @@ -import type { ZodOpenApiResponseObject } from "zod-openapi"; import { NextResponse } from "next/server"; import { generateErrorMessage } from "zod-error"; import { z, ZodError } from "zod/v4"; @@ -21,7 +20,10 @@ export const ErrorCode = z.enum([ const docsBase = "https://docs.agentset.ai"; -export const errorCodeToHttpStatus: Record, number> = { +export const errorCodeToHttpStatus: Record< + z.infer, + number +> = { bad_request: 400, unauthorized: 401, forbidden: 403, @@ -190,10 +192,26 @@ export function handleAndReturnErrorResponse( ); } +/** + * Minimal OpenAPI response-object shape produced by `errorSchemaFactory` + * (replaces the old zod-openapi `ZodOpenApiResponseObject` type). The `id` + * was consumed by zod-openapi to dedupe responses into components; the oRPC + * spec builder keys components by status string and drops it. + */ +export interface OpenApiErrorResponseObject { + id: string; + description: string; + content: { + "application/json": { + schema: Record; + }; + }; +} + export const errorSchemaFactory = ( code: z.infer, description: string, -): ZodOpenApiResponseObject => { +): OpenApiErrorResponseObject => { return { id: errorCodeToHttpStatus[code].toString(), description, diff --git a/apps/web/src/lib/api/tenant.ts b/apps/web/src/lib/api/tenant.ts index 14fd63a8..300d20c2 100644 --- a/apps/web/src/lib/api/tenant.ts +++ b/apps/web/src/lib/api/tenant.ts @@ -1,5 +1,5 @@ import type { NextRequest } from "next/server"; -import { tenantHeaderSchema } from "@/openapi/v1/utils"; +import { tenantHeaderSchema } from "@/schemas/api/params"; import { AgentsetApiError } from "./errors"; diff --git a/apps/web/src/openapi/index.ts b/apps/web/src/openapi/index.ts deleted file mode 100644 index ce3b3dda..00000000 --- a/apps/web/src/openapi/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createDocument } from "zod-openapi"; - -import { webhookEventSchema } from "@agentset/webhooks"; - -import { v1Paths } from "./v1"; - -export const createOpenApiDocument = () => { - return createDocument({ - openapi: "3.1.1", - info: { - title: "AgentsetAPI", - description: "Agentset is agentic rag-as-a-service", - version: "0.0.1", - contact: { - name: "Agentset Support", - email: "support@agentset.ai", - url: "https://api.agentset.ai/", - }, - license: { - name: "MIT License", - url: "https://github.com/agentset-ai/agentset/blob/main/LICENSE.md", - }, - }, - servers: [ - { - url: "https://api.agentset.ai", - description: "Production API", - }, - ], - "x-speakeasy-globals": { - parameters: [ - { - $ref: "#/components/parameters/NamespaceIdRef", - }, - { - $ref: "#/components/parameters/TenantIdRef", - }, - ], - }, - paths: { - ...v1Paths, - }, - components: { - schemas: { - webhookEventSchema, - }, - securitySchemes: { - token: { - type: "http", - description: "Default authentication mechanism", - scheme: "bearer", - "x-speakeasy-example": "AGENTSET_API_KEY", - }, - }, - }, - }); -}; diff --git a/apps/web/src/openapi/responses.ts b/apps/web/src/openapi/responses.ts deleted file mode 100644 index c0c44b05..00000000 --- a/apps/web/src/openapi/responses.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ZodOpenApiComponentsObject } from "zod-openapi"; -import { errorSchemaFactory } from "@/lib/api/errors"; -import { z } from "zod/v4"; - -export const openApiErrorResponses: ZodOpenApiComponentsObject["responses"] = { - "400": errorSchemaFactory( - "bad_request", - "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", - ), - - "401": errorSchemaFactory( - "unauthorized", - `Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.`, - ), - - "403": errorSchemaFactory( - "forbidden", - "The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server.", - ), - - "404": errorSchemaFactory( - "not_found", - "The server cannot find the requested resource.", - ), - - "409": errorSchemaFactory( - "conflict", - "This response is sent when a request conflicts with the current state of the server.", - ), - - "410": errorSchemaFactory( - "invite_expired", - "This response is sent when the requested content has been permanently deleted from server, with no forwarding address.", - ), - - "422": errorSchemaFactory( - "unprocessable_entity", - "The request was well-formed but was unable to be followed due to semantic errors.", - ), - - "429": errorSchemaFactory( - "rate_limit_exceeded", - `The user has sent too many requests in a given amount of time ("rate limiting")`, - ), - - "500": errorSchemaFactory( - "internal_server_error", - "The server has encountered a situation it does not know how to handle.", - ), -}; - -export const successSchema = ( - data: z.ZodType, - { hasPagination }: { hasPagination?: boolean } = {}, -) => - z.object({ - success: z.literal(true), - data, - ...(hasPagination && { - pagination: z.object({ - nextCursor: z.string().nullable(), - prevCursor: z.string().nullable(), - hasMore: z.boolean(), - }), - }), - }); diff --git a/apps/web/src/openapi/v1/api-keys/create-api-key.ts b/apps/web/src/openapi/v1/api-keys/create-api-key.ts deleted file mode 100644 index c17b4669..00000000 --- a/apps/web/src/openapi/v1/api-keys/create-api-key.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { - createApiKeyBodySchema, - CreatedApiKeySchema, -} from "@/schemas/api/api-key"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const createApiKey: ZodOpenApiOperationObject = { - operationId: "createApiKey", - "x-speakeasy-name-override": "create", - summary: "Create an API key", - description: - "Create an API key for the authenticated organization. The full key is only returned once, at creation time.", - requestBody: { - required: true, - content: { - "application/json": { schema: createApiKeyBodySchema }, - }, - }, - responses: { - "201": { - description: "The created API key", - content: { - "application/json": { - schema: successSchema(CreatedApiKeySchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["API Keys"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const apiKey = await agentset.apiKeys.create({ - label: "My API Key", -}); -console.log(apiKey.key); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/api-keys/delete-api-key.ts b/apps/web/src/openapi/v1/api-keys/delete-api-key.ts deleted file mode 100644 index 5aebe8e4..00000000 --- a/apps/web/src/openapi/v1/api-keys/delete-api-key.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses } from "@/openapi/responses"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; - -const keyIdPathSchema = z.string().meta({ - examples: ["cm4x1q2z90000abcd1234efgh"], - description: "The id of the API key.", - param: { - in: "path", - name: "keyId", - id: "ApiKeyIdRef", - }, -}); - -export const deleteApiKey: ZodOpenApiOperationObject = { - operationId: "deleteApiKey", - "x-speakeasy-name-override": "delete", - "x-speakeasy-max-method-params": 1, - summary: "Delete an API key", - description: - "Delete an API key for the authenticated organization. The key is revoked immediately.", - parameters: [keyIdPathSchema], - responses: { - "204": { - description: "The API key was deleted", - }, - ...openApiErrorResponses, - }, - tags: ["API Keys"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -await agentset.apiKeys.delete("cm4x1q2z90000abcd1234efgh"); -console.log("API key deleted"); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/api-keys/index.ts b/apps/web/src/openapi/v1/api-keys/index.ts deleted file mode 100644 index 46de834a..00000000 --- a/apps/web/src/openapi/v1/api-keys/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { createApiKey } from "./create-api-key"; -import { deleteApiKey } from "./delete-api-key"; -import { listApiKeys } from "./list-api-keys"; - -export const apiKeysPaths: ZodOpenApiPathsObject = { - "/v1/api-keys": { - get: listApiKeys, - post: createApiKey, - }, - "/v1/api-keys/{keyId}": { - delete: deleteApiKey, - }, -}; diff --git a/apps/web/src/openapi/v1/api-keys/list-api-keys.ts b/apps/web/src/openapi/v1/api-keys/list-api-keys.ts deleted file mode 100644 index fcd57bfe..00000000 --- a/apps/web/src/openapi/v1/api-keys/list-api-keys.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { ApiKeySchema } from "@/schemas/api/api-key"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const listApiKeys: ZodOpenApiOperationObject = { - operationId: "listApiKeys", - "x-speakeasy-name-override": "list", - summary: "Retrieve a list of API keys", - description: - "Retrieve a list of API keys for the authenticated organization. The key material is never returned.", - responses: { - "200": { - description: "The retrieved API keys", - content: { - "application/json": { - schema: successSchema(z.array(ApiKeySchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["API Keys"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const apiKeys = await agentset.apiKeys.list(); -console.log(apiKeys); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/chat.ts b/apps/web/src/openapi/v1/chat.ts deleted file mode 100644 index 02ab5807..00000000 --- a/apps/web/src/openapi/v1/chat.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { - ZodOpenApiOperationObject, - ZodOpenApiPathsObject, -} from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { chatResponseSchema, chatSchema } from "@/schemas/api/chat"; - -import { makeCodeSamples, ts } from "./code-samples"; -import { namespaceIdPathSchema, tenantHeaderSchema } from "./utils"; - -export const chat: ZodOpenApiOperationObject = { - operationId: "chat", - "x-speakeasy-name-override": "execute", - "x-speakeasy-group": "chat", - summary: "Chat with a namespace", - description: - "Generate an answer to a conversation using the documents in a namespace (RAG). Supports `normal`, `agentic`, and `deepResearch` modes. When `stream` is `true`, the response is a `text/event-stream` of AI SDK UI message parts (including a `data-agentset-sources` part with the retrieved chunks) instead of the JSON envelope documented below.", - parameters: [namespaceIdPathSchema, tenantHeaderSchema], - requestBody: { - required: true, - content: { - "application/json": { - schema: chatSchema, - }, - }, - }, - responses: { - "200": { - description: "The generated answer and the sources used to generate it", - content: { - "application/json": { - schema: successSchema(chatResponseSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Chat"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const result = await ns.chat({ - messages: [{ role: "user", content: "What is machine learning?" }], - topK: 20, - rerank: true, -}); -console.log(result.message.content); -`), -}; - -export const chatPaths: ZodOpenApiPathsObject = { - "/v1/namespace/{namespaceId}/chat": { - post: chat, - }, -}; diff --git a/apps/web/src/openapi/v1/code-samples.ts b/apps/web/src/openapi/v1/code-samples.ts deleted file mode 100644 index e032a4a6..00000000 --- a/apps/web/src/openapi/v1/code-samples.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const ts = (statements: TemplateStringsArray) => { - return statements.join("\n").trim(); -}; - -// We're not using Speakeasy for the TS SDK and instead maintain one ourselves. -// This helper function adds TS code samples to the operation object. -// It also converts the TS code to JS -export const makeCodeSamples = ( - code: string, - { isNs = true }: { isNs?: boolean } = {}, -) => { - const fullCode = ` -import { Agentset } from "agentset"; -${code.includes("fs.") ? "import fs from 'fs';\n" : ""} -const agentset = new Agentset({ apiKey: 'agentset_xxx' }); -${isNs ? "const ns = agentset.namespace('ns_xxx');\n" : ""} -${code} -`; - - return { - "x-codeSamples": [ - { - lang: "TypeScript", - source: fullCode, - }, - ], - }; -}; diff --git a/apps/web/src/openapi/v1/documents/delete-document.ts b/apps/web/src/openapi/v1/documents/delete-document.ts deleted file mode 100644 index 0534ea65..00000000 --- a/apps/web/src/openapi/v1/documents/delete-document.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { DocumentSchema } from "@/schemas/api/document"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { - documentIdPathSchema, - namespaceIdPathSchema, - tenantHeaderSchema, -} from "../utils"; - -export const deleteDocument: ZodOpenApiOperationObject = { - operationId: "deleteDocument", - "x-speakeasy-name-override": "delete", - "x-speakeasy-max-method-params": 1, - summary: "Delete a document", - description: "Delete a document for the authenticated organization.", - parameters: [namespaceIdPathSchema, documentIdPathSchema, tenantHeaderSchema], - responses: { - "204": { - description: "The deleted document", - content: { - "application/json": { - schema: successSchema(DocumentSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Documents"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -await ns.documents.delete("doc_123"); -console.log("Document queued for deletion"); -`), -}; diff --git a/apps/web/src/openapi/v1/documents/get-chunks-download-url.ts b/apps/web/src/openapi/v1/documents/get-chunks-download-url.ts deleted file mode 100644 index 93a96c85..00000000 --- a/apps/web/src/openapi/v1/documents/get-chunks-download-url.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { documentIdPathSchema, namespaceIdPathSchema } from "../utils"; - -export const getChunksDownloadUrl: ZodOpenApiOperationObject = { - operationId: "getChunksDownloadUrl", - "x-speakeasy-name-override": "getChunksDownloadUrl", - summary: "Get chunks download URL", - description: - "Get a presigned download URL for a document's chunks. Only available for completed documents.", - parameters: [namespaceIdPathSchema, documentIdPathSchema], - responses: { - "200": { - description: "The presigned download URL for the chunks", - content: { - "application/json": { - schema: successSchema(z.object({ url: z.string() })), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Documents"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const { url } = await ns.documents.getChunksDownloadUrl("doc_123"); -const data = await (await fetch(url)).json(); -console.log(data); -`), -}; diff --git a/apps/web/src/openapi/v1/documents/get-document.ts b/apps/web/src/openapi/v1/documents/get-document.ts deleted file mode 100644 index 81db7f44..00000000 --- a/apps/web/src/openapi/v1/documents/get-document.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { DocumentSchema } from "@/schemas/api/document"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { - documentIdPathSchema, - namespaceIdPathSchema, - tenantHeaderSchema, -} from "../utils"; - -export const getDocument: ZodOpenApiOperationObject = { - operationId: "getDocument", - "x-speakeasy-name-override": "get", - summary: "Retrieve a document", - description: "Retrieve the info for a document.", - parameters: [namespaceIdPathSchema, documentIdPathSchema, tenantHeaderSchema], - responses: { - "200": { - description: "The retrieved ingest job", - content: { - "application/json": { - schema: successSchema(DocumentSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Documents"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const document = await ns.documents.get("doc_123"); -console.log(document); -`), -}; diff --git a/apps/web/src/openapi/v1/documents/get-file-download-url.ts b/apps/web/src/openapi/v1/documents/get-file-download-url.ts deleted file mode 100644 index 62a1ad13..00000000 --- a/apps/web/src/openapi/v1/documents/get-file-download-url.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { documentIdPathSchema, namespaceIdPathSchema } from "../utils"; - -export const getFileDownloadUrl: ZodOpenApiOperationObject = { - operationId: "getFileDownloadUrl", - "x-speakeasy-name-override": "getFileDownloadUrl", - summary: "Get file download URL", - description: - "Get a presigned download URL for a document's source file. Only available for documents with source type MANAGED_FILE.", - parameters: [namespaceIdPathSchema, documentIdPathSchema], - responses: { - "200": { - description: "The presigned download URL for the file", - content: { - "application/json": { - schema: successSchema(z.object({ url: z.string() })), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Documents"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const { url } = await ns.documents.getFileDownloadUrl("doc_123"); -const file = await fetch(url); -fs.writeFileSync("file.pdf", Buffer.from(await file.arrayBuffer())); -`), -}; diff --git a/apps/web/src/openapi/v1/documents/index.ts b/apps/web/src/openapi/v1/documents/index.ts deleted file mode 100644 index 9e59074d..00000000 --- a/apps/web/src/openapi/v1/documents/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { deleteDocument } from "./delete-document"; -import { getChunksDownloadUrl } from "./get-chunks-download-url"; -import { getDocument } from "./get-document"; -import { getFileDownloadUrl } from "./get-file-download-url"; -import { listDocuments } from "./list-documents"; - -export const documentsPaths: ZodOpenApiPathsObject = { - "/v1/namespace/{namespaceId}/documents": { - get: listDocuments, - }, - "/v1/namespace/{namespaceId}/documents/{documentId}": { - get: getDocument, - delete: deleteDocument, - }, - "/v1/namespace/{namespaceId}/documents/{documentId}/chunks-download-url": { - post: getChunksDownloadUrl, - }, - "/v1/namespace/{namespaceId}/documents/{documentId}/file-download-url": { - post: getFileDownloadUrl, - }, -}; diff --git a/apps/web/src/openapi/v1/documents/list-documents.ts b/apps/web/src/openapi/v1/documents/list-documents.ts deleted file mode 100644 index 3b00aea3..00000000 --- a/apps/web/src/openapi/v1/documents/list-documents.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { DocumentSchema, getDocumentsSchema } from "@/schemas/api/document"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema, tenantHeaderSchema } from "../utils"; - -export const listDocuments: ZodOpenApiOperationObject = { - operationId: "listDocuments", - "x-speakeasy-name-override": "list", - "x-speakeasy-pagination": { - type: "cursor", - inputs: [ - { - name: "cursor", - in: "parameters", - type: "cursor", - }, - ], - outputs: { - nextCursor: "$.pagination.nextCursor", - }, - }, - summary: "Retrieve a list of documents", - description: - "Retrieve a paginated list of documents for the authenticated organization.", - parameters: [namespaceIdPathSchema, tenantHeaderSchema], - requestParams: { - query: getDocumentsSchema, - }, - responses: { - "200": { - description: "The retrieved ingest jobs", - content: { - "application/json": { - schema: successSchema(z.array(DocumentSchema), { - hasPagination: true, - }), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Documents"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const docs = await ns.documents.all(); -console.log(docs); -`), -}; diff --git a/apps/web/src/openapi/v1/hosting/add-domain.ts b/apps/web/src/openapi/v1/hosting/add-domain.ts deleted file mode 100644 index 8dffa01c..00000000 --- a/apps/web/src/openapi/v1/hosting/add-domain.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { addDomainSchema, DomainSchema } from "@/schemas/api/hosting"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const addDomain: ZodOpenApiOperationObject = { - operationId: "addDomain", - "x-speakeasy-name-override": "addDomain", - "x-speakeasy-max-method-params": 1, - summary: "Add a custom domain", - description: - "Attach a custom domain to the hosting configuration of a namespace. Only one domain can be attached at a time.", - parameters: [namespaceIdPathSchema], - requestBody: { - required: true, - content: { - "application/json": { schema: addDomainSchema }, - }, - }, - responses: { - "201": { - description: "The added domain", - content: { - "application/json": { - schema: successSchema(DomainSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Hosting"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const domain = await ns.hosting.addDomain({ domain: "docs.example.com" }); -console.log(domain); -`, - ), -}; diff --git a/apps/web/src/openapi/v1/hosting/check-domain-status.ts b/apps/web/src/openapi/v1/hosting/check-domain-status.ts deleted file mode 100644 index 2278171b..00000000 --- a/apps/web/src/openapi/v1/hosting/check-domain-status.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { DomainStatusSchema } from "@/schemas/api/hosting"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const checkDomainStatus: ZodOpenApiOperationObject = { - operationId: "checkDomainStatus", - "x-speakeasy-name-override": "checkDomainStatus", - summary: "Retrieve custom domain status", - description: - "Retrieve the DNS configuration status of the custom domain attached to a namespace's hosting. If the domain is pending verification, a verification attempt is made automatically.", - parameters: [namespaceIdPathSchema], - responses: { - "200": { - description: "The domain status", - content: { - "application/json": { - schema: successSchema(DomainStatusSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Hosting"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const status = await ns.hosting.checkDomainStatus(); -console.log(status); -`, - ), -}; diff --git a/apps/web/src/openapi/v1/hosting/delete-hosting.ts b/apps/web/src/openapi/v1/hosting/delete-hosting.ts deleted file mode 100644 index 1590e026..00000000 --- a/apps/web/src/openapi/v1/hosting/delete-hosting.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses } from "@/openapi/responses"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const deleteHosting: ZodOpenApiOperationObject = { - operationId: "deleteHosting", - "x-speakeasy-name-override": "delete", - summary: "Delete hosting configuration", - description: - "Delete the hosting configuration for a namespace. Also removes the attached custom domain, if any.", - parameters: [namespaceIdPathSchema], - responses: { - "204": { - description: "The hosting configuration was deleted", - }, - ...openApiErrorResponses, - }, - tags: ["Hosting"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -await ns.hosting.delete(); -console.log("Hosting deleted"); -`, - ), -}; diff --git a/apps/web/src/openapi/v1/hosting/enable-hosting.ts b/apps/web/src/openapi/v1/hosting/enable-hosting.ts deleted file mode 100644 index ee7c6a3b..00000000 --- a/apps/web/src/openapi/v1/hosting/enable-hosting.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { HostingSchema } from "@/schemas/api/hosting"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const enableHosting: ZodOpenApiOperationObject = { - operationId: "enableHosting", - "x-speakeasy-name-override": "enable", - summary: "Enable hosting", - description: "Enable hosting for a namespace.", - parameters: [namespaceIdPathSchema], - responses: { - "201": { - description: "The created hosting configuration", - content: { - "application/json": { - schema: successSchema(HostingSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Hosting"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const hosting = await ns.hosting.enable(); -console.log(hosting); -`, - ), -}; diff --git a/apps/web/src/openapi/v1/hosting/get-hosting.ts b/apps/web/src/openapi/v1/hosting/get-hosting.ts deleted file mode 100644 index 5f0f2152..00000000 --- a/apps/web/src/openapi/v1/hosting/get-hosting.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { HostingSchema } from "@/schemas/api/hosting"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const getHosting: ZodOpenApiOperationObject = { - operationId: "getHosting", - "x-speakeasy-name-override": "get", - summary: "Retrieve hosting configuration", - description: "Retrieve the hosting configuration for a namespace.", - parameters: [namespaceIdPathSchema], - responses: { - "200": { - description: "The hosting configuration", - content: { - "application/json": { - schema: successSchema(HostingSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Hosting"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const hosting = await ns.hosting.get(); -console.log(hosting); -`, - ), -}; diff --git a/apps/web/src/openapi/v1/hosting/index.ts b/apps/web/src/openapi/v1/hosting/index.ts deleted file mode 100644 index 54151e58..00000000 --- a/apps/web/src/openapi/v1/hosting/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { addDomain } from "./add-domain"; -import { checkDomainStatus } from "./check-domain-status"; -import { deleteHosting } from "./delete-hosting"; -import { enableHosting } from "./enable-hosting"; -import { getHosting } from "./get-hosting"; -import { removeDomain } from "./remove-domain"; -import { updateHosting } from "./update-hosting"; - -export const hostingPaths: ZodOpenApiPathsObject = { - "/v1/namespace/{namespaceId}/hosting": { - get: getHosting, - post: enableHosting, - patch: updateHosting, - delete: deleteHosting, - }, - "/v1/namespace/{namespaceId}/hosting/domain": { - get: checkDomainStatus, - post: addDomain, - delete: removeDomain, - }, -}; diff --git a/apps/web/src/openapi/v1/hosting/remove-domain.ts b/apps/web/src/openapi/v1/hosting/remove-domain.ts deleted file mode 100644 index b02103c6..00000000 --- a/apps/web/src/openapi/v1/hosting/remove-domain.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses } from "@/openapi/responses"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const removeDomain: ZodOpenApiOperationObject = { - operationId: "removeDomain", - "x-speakeasy-name-override": "removeDomain", - summary: "Remove the custom domain", - description: - "Remove the custom domain attached to the hosting configuration of a namespace.", - parameters: [namespaceIdPathSchema], - responses: { - "204": { - description: "The domain was removed", - }, - ...openApiErrorResponses, - }, - tags: ["Hosting"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -await ns.hosting.removeDomain(); -console.log("Domain removed"); -`, - ), -}; diff --git a/apps/web/src/openapi/v1/hosting/update-hosting.ts b/apps/web/src/openapi/v1/hosting/update-hosting.ts deleted file mode 100644 index a9f23615..00000000 --- a/apps/web/src/openapi/v1/hosting/update-hosting.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { HostingSchema, updateHostingSchema } from "@/schemas/api/hosting"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const updateHosting: ZodOpenApiOperationObject = { - operationId: "updateHosting", - "x-speakeasy-name-override": "update", - "x-speakeasy-max-method-params": 1, - summary: "Update hosting configuration", - description: - "Update the hosting configuration for a namespace. If there is no change, return it as it is.", - parameters: [namespaceIdPathSchema], - requestBody: { - required: true, - content: { - "application/json": { schema: updateHostingSchema }, - }, - }, - responses: { - "200": { - description: "The updated hosting configuration", - content: { - "application/json": { - schema: successSchema(HostingSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Hosting"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const updatedHosting = await ns.hosting.update({ - title: "My Knowledge Base", - welcomeMessage: "Welcome to my knowledge base!", - searchEnabled: true, -}); -console.log(updatedHosting); -`, - ), -}; diff --git a/apps/web/src/openapi/v1/index.ts b/apps/web/src/openapi/v1/index.ts deleted file mode 100644 index e6ee0e70..00000000 --- a/apps/web/src/openapi/v1/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { apiKeysPaths } from "./api-keys"; -import { chatPaths } from "./chat"; -import { documentsPaths } from "./documents"; -import { hostingPaths } from "./hosting"; -import { ingestJobsPaths } from "./ingest-jobs"; -import { namespacesPaths } from "./namespaces"; -import { organizationPaths } from "./organization"; -import { searchPaths } from "./search"; -import { uploadsPaths } from "./uploads"; -import { warmUpPaths } from "./warm-up"; -import { webhooksPaths } from "./webhooks"; - -export const v1Paths: ZodOpenApiPathsObject = { - ...namespacesPaths, - ...ingestJobsPaths, - ...documentsPaths, - ...searchPaths, - ...chatPaths, - ...uploadsPaths, - ...hostingPaths, - ...warmUpPaths, - ...webhooksPaths, - ...organizationPaths, - ...apiKeysPaths, -}; diff --git a/apps/web/src/openapi/v1/ingest-jobs/create-job.ts b/apps/web/src/openapi/v1/ingest-jobs/create-job.ts deleted file mode 100644 index 0d84ca67..00000000 --- a/apps/web/src/openapi/v1/ingest-jobs/create-job.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { - createIngestJobSchema, - IngestJobSchema, -} from "@/schemas/api/ingest-job"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema, tenantHeaderSchema } from "../utils"; - -export const createIngestJob: ZodOpenApiOperationObject = { - operationId: "createIngestJob", - "x-speakeasy-name-override": "create", - "x-speakeasy-group": "ingestJobs", - summary: "Create an ingest job", - description: - "Create an ingest job for the authenticated organization. You can control how documents are parsed and chunked using the optional `config` object (for example, chunk size, overlap, language, and advanced OCR/LLM options).", - parameters: [namespaceIdPathSchema, tenantHeaderSchema], - requestBody: { - required: true, - content: { - "application/json": { schema: createIngestJobSchema }, - }, - }, - responses: { - "201": { - description: "The created ingest job", - content: { - "application/json": { - schema: successSchema(IngestJobSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Ingest Jobs"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const job = await ns.ingestion.create({ - payload: { - type: "TEXT", - text: "This is some content to ingest into the knowledge base.", - }, - config: { - metadata: { - foo: "bar", - }, - chunkSize: 2048, - }, -}); -console.log(job); -`), -}; diff --git a/apps/web/src/openapi/v1/ingest-jobs/delete-job.ts b/apps/web/src/openapi/v1/ingest-jobs/delete-job.ts deleted file mode 100644 index b12bd3d5..00000000 --- a/apps/web/src/openapi/v1/ingest-jobs/delete-job.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { IngestJobSchema } from "@/schemas/api/ingest-job"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { - jobIdPathSchema, - namespaceIdPathSchema, - tenantHeaderSchema, -} from "../utils"; - -export const deleteIngestJob: ZodOpenApiOperationObject = { - operationId: "deleteIngestJob", - "x-speakeasy-name-override": "delete", - "x-speakeasy-group": "ingestJobs", - "x-speakeasy-max-method-params": 1, - summary: "Delete an ingest job", - description: "Delete an ingest job for the authenticated organization.", - parameters: [namespaceIdPathSchema, jobIdPathSchema, tenantHeaderSchema], - responses: { - "204": { - description: "The deleted ingest job", - content: { - "application/json": { - schema: successSchema(IngestJobSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Ingest Jobs"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -await ns.ingestion.delete("job_123"); -console.log("Ingest job queued for deletion"); -`), -}; diff --git a/apps/web/src/openapi/v1/ingest-jobs/get-job.ts b/apps/web/src/openapi/v1/ingest-jobs/get-job.ts deleted file mode 100644 index 552a34ab..00000000 --- a/apps/web/src/openapi/v1/ingest-jobs/get-job.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { IngestJobSchema } from "@/schemas/api/ingest-job"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { - jobIdPathSchema, - namespaceIdPathSchema, - tenantHeaderSchema, -} from "../utils"; - -export const getIngestJobInfo: ZodOpenApiOperationObject = { - operationId: "getIngestJobInfo", - "x-speakeasy-name-override": "get", - "x-speakeasy-group": "ingestJobs", - summary: "Retrieve an ingest job", - description: "Retrieve the info for an ingest job.", - parameters: [namespaceIdPathSchema, jobIdPathSchema, tenantHeaderSchema], - responses: { - "200": { - description: "The retrieved ingest job", - content: { - "application/json": { - schema: successSchema(IngestJobSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Ingest Jobs"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const job = await ns.ingestion.get("job_123"); -console.log(job); -`), -}; diff --git a/apps/web/src/openapi/v1/ingest-jobs/index.ts b/apps/web/src/openapi/v1/ingest-jobs/index.ts deleted file mode 100644 index 65374c64..00000000 --- a/apps/web/src/openapi/v1/ingest-jobs/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { createIngestJob } from "./create-job"; -import { deleteIngestJob } from "./delete-job"; -import { getIngestJobInfo } from "./get-job"; -import { listIngestJobs } from "./list-jobs"; -import { reIngestJob } from "./reingest-job"; - -export const ingestJobsPaths: ZodOpenApiPathsObject = { - "/v1/namespace/{namespaceId}/ingest-jobs": { - get: listIngestJobs, - post: createIngestJob, - }, - "/v1/namespace/{namespaceId}/ingest-jobs/{jobId}": { - get: getIngestJobInfo, - delete: deleteIngestJob, - }, - "/v1/namespace/{namespaceId}/ingest-jobs/{jobId}/re-ingest": { - post: reIngestJob, - }, -}; diff --git a/apps/web/src/openapi/v1/ingest-jobs/list-jobs.ts b/apps/web/src/openapi/v1/ingest-jobs/list-jobs.ts deleted file mode 100644 index fc0c6d79..00000000 --- a/apps/web/src/openapi/v1/ingest-jobs/list-jobs.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { - getIngestionJobsSchema, - IngestJobSchema, -} from "@/schemas/api/ingest-job"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema, tenantHeaderSchema } from "../utils"; - -export const listIngestJobs: ZodOpenApiOperationObject = { - operationId: "listIngestJobs", - "x-speakeasy-name-override": "list", - "x-speakeasy-group": "ingestJobs", - "x-speakeasy-pagination": { - type: "cursor", - inputs: [ - { - name: "cursor", - in: "parameters", - type: "cursor", - }, - ], - outputs: { - nextCursor: "$.pagination.nextCursor", - }, - }, - summary: "Retrieve a list of ingest jobs", - description: - "Retrieve a paginated list of ingest jobs for the authenticated organization.", - parameters: [namespaceIdPathSchema, tenantHeaderSchema], - requestParams: { - query: getIngestionJobsSchema, - }, - responses: { - "200": { - description: "The retrieved ingest jobs", - content: { - "application/json": { - schema: successSchema(z.array(IngestJobSchema), { - hasPagination: true, - }), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Ingest Jobs"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const jobs = await ns.ingestion.all(); -console.log(jobs); -`), -}; diff --git a/apps/web/src/openapi/v1/ingest-jobs/reingest-job.ts b/apps/web/src/openapi/v1/ingest-jobs/reingest-job.ts deleted file mode 100644 index 19618fcd..00000000 --- a/apps/web/src/openapi/v1/ingest-jobs/reingest-job.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { IngestJobSchema } from "@/schemas/api/ingest-job"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { - jobIdPathSchema, - namespaceIdPathSchema, - tenantHeaderSchema, -} from "../utils"; - -export const reIngestJob: ZodOpenApiOperationObject = { - operationId: "reIngestJob", - "x-speakeasy-name-override": "reIngest", - "x-speakeasy-group": "ingestJobs", - "x-speakeasy-max-method-params": 1, - summary: "Re-ingest a job", - description: "Re-ingest a job for the authenticated organization.", - parameters: [namespaceIdPathSchema, jobIdPathSchema, tenantHeaderSchema], - responses: { - "200": { - description: "The re-ingested job", - content: { - "application/json": { - schema: successSchema(IngestJobSchema.pick({ id: true })), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Ingest Jobs"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const result = await ns.ingestion.reIngest("job_123"); -console.log("Job queued for re-ingestion: ", result); -`), -}; diff --git a/apps/web/src/openapi/v1/namespaces/create-namespace.ts b/apps/web/src/openapi/v1/namespaces/create-namespace.ts deleted file mode 100644 index 8c56700a..00000000 --- a/apps/web/src/openapi/v1/namespaces/create-namespace.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { - createNamespaceSchema, - NamespaceSchema, -} from "@/schemas/api/namespace"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const createNamespace: ZodOpenApiOperationObject = { - operationId: "createNamespace", - "x-speakeasy-name-override": "create", - "x-speakeasy-usage-example": true, - summary: "Create a namespace.", - description: "Create a namespace for the authenticated organization.", - requestBody: { - required: true, - content: { - "application/json": { schema: createNamespaceSchema }, - }, - }, - responses: { - "201": { - description: "The created namespace", - content: { - "application/json": { - schema: successSchema(NamespaceSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Namespaces"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const namespace = await agentset.namespaces.create({ - name: "My Knowledge Base", - slug: "my-knowledge-base", - // embeddingConfig: {...}, - // vectorStoreConfig: {...}, -}); -console.log(namespace); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/namespaces/delete-namespace.ts b/apps/web/src/openapi/v1/namespaces/delete-namespace.ts deleted file mode 100644 index f90c2c72..00000000 --- a/apps/web/src/openapi/v1/namespaces/delete-namespace.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses } from "@/openapi/responses"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const deleteNamespace: ZodOpenApiOperationObject = { - operationId: "deleteNamespace", - "x-speakeasy-name-override": "delete", - "x-speakeasy-max-method-params": 1, - summary: "Delete a namespace.", - description: - "Delete a namespace for the authenticated organization. This will delete all the data associated with the namespace.", - parameters: [namespaceIdPathSchema], - responses: { - "204": { - description: "The namespace was queued for deletion.", - }, - ...openApiErrorResponses, - }, - tags: ["Namespaces"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -await agentset.namespaces.delete("ns_xxx"); -console.log("Namespace queued for deletion"); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/namespaces/get-namespace.ts b/apps/web/src/openapi/v1/namespaces/get-namespace.ts deleted file mode 100644 index b4897764..00000000 --- a/apps/web/src/openapi/v1/namespaces/get-namespace.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { NamespaceSchema } from "@/schemas/api/namespace"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const getNamespace: ZodOpenApiOperationObject = { - operationId: "getNamespace", - "x-speakeasy-name-override": "get", - summary: "Retrieve a namespace", - description: "Retrieve the info for a namespace.", - parameters: [namespaceIdPathSchema], - responses: { - "200": { - description: "The retrieved namespace", - content: { - "application/json": { - schema: successSchema(NamespaceSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Namespaces"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const namespace = await agentset.namespaces.get("ns_xxx"); -console.log(namespace); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/namespaces/index.ts b/apps/web/src/openapi/v1/namespaces/index.ts deleted file mode 100644 index 3b97ac2a..00000000 --- a/apps/web/src/openapi/v1/namespaces/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { createNamespace } from "./create-namespace"; -import { deleteNamespace } from "./delete-namespace"; -import { getNamespace } from "./get-namespace"; -import { listNamespaces } from "./list-namespaces"; -import { updateNamespace } from "./update-namespace"; - -export const namespacesPaths: ZodOpenApiPathsObject = { - "/v1/namespace": { - get: listNamespaces, - post: createNamespace, - }, - "/v1/namespace/{namespaceId}": { - get: getNamespace, - patch: updateNamespace, - delete: deleteNamespace, - }, -}; diff --git a/apps/web/src/openapi/v1/namespaces/list-namespaces.ts b/apps/web/src/openapi/v1/namespaces/list-namespaces.ts deleted file mode 100644 index 0096b80a..00000000 --- a/apps/web/src/openapi/v1/namespaces/list-namespaces.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { NamespaceSchema } from "@/schemas/api/namespace"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const listNamespaces: ZodOpenApiOperationObject = { - operationId: "listNamespaces", - "x-speakeasy-name-override": "list", - summary: "Retrieve a list of namespaces", - description: - "Retrieve a list of namespaces for the authenticated organization.", - responses: { - "200": { - description: "The retrieved namespaces", - content: { - "application/json": { - schema: successSchema(z.array(NamespaceSchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Namespaces"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const namespaces = await agentset.namespaces.list(); -console.log(namespaces); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/namespaces/update-namespace.ts b/apps/web/src/openapi/v1/namespaces/update-namespace.ts deleted file mode 100644 index fea1c3ce..00000000 --- a/apps/web/src/openapi/v1/namespaces/update-namespace.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { - NamespaceSchema, - updateNamespaceSchema, -} from "@/schemas/api/namespace"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const updateNamespace: ZodOpenApiOperationObject = { - operationId: "updateNamespace", - "x-speakeasy-name-override": "update", - "x-speakeasy-max-method-params": 2, - summary: "Update a namespace.", - description: - "Update a namespace for the authenticated organization. If there is no change, return it as it is.", - parameters: [namespaceIdPathSchema], - requestBody: { - required: true, - content: { - "application/json": { schema: updateNamespaceSchema }, - }, - }, - responses: { - "200": { - description: "The updated namespace", - content: { - "application/json": { - schema: successSchema(NamespaceSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Namespaces"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const updatedNamespace = await agentset.namespaces.update("ns_xxx", { - name: "Updated Knowledge Base", -}); -console.log(updatedNamespace); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/organization/get-organization.ts b/apps/web/src/openapi/v1/organization/get-organization.ts deleted file mode 100644 index d85e0edd..00000000 --- a/apps/web/src/openapi/v1/organization/get-organization.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { OrganizationSchema } from "@/schemas/api/organization"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const getOrganization: ZodOpenApiOperationObject = { - operationId: "getOrganization", - "x-speakeasy-name-override": "get", - summary: "Retrieve the organization", - description: - "Retrieve the organization associated with the API key, including its usage and limits.", - responses: { - "200": { - description: "The retrieved organization", - content: { - "application/json": { - schema: successSchema(OrganizationSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Organization"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const organization = await agentset.organization.get(); -console.log(organization); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/organization/index.ts b/apps/web/src/openapi/v1/organization/index.ts deleted file mode 100644 index d2e573e7..00000000 --- a/apps/web/src/openapi/v1/organization/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { getOrganization } from "./get-organization"; -import { listOrganizationMembers } from "./list-members"; -import { updateOrganization } from "./update-organization"; - -export const organizationPaths: ZodOpenApiPathsObject = { - "/v1/organization": { - get: getOrganization, - patch: updateOrganization, - }, - "/v1/organization/members": { - get: listOrganizationMembers, - }, -}; diff --git a/apps/web/src/openapi/v1/organization/list-members.ts b/apps/web/src/openapi/v1/organization/list-members.ts deleted file mode 100644 index 68f79a8a..00000000 --- a/apps/web/src/openapi/v1/organization/list-members.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { OrganizationMembersSchema } from "@/schemas/api/organization"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const listOrganizationMembers: ZodOpenApiOperationObject = { - operationId: "listOrganizationMembers", - "x-speakeasy-name-override": "members", - summary: "Retrieve the organization members", - description: - "Retrieve the members and pending invitations of the organization associated with the API key.", - responses: { - "200": { - description: "The retrieved members and pending invitations", - content: { - "application/json": { - schema: successSchema(OrganizationMembersSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Organization"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const members = await agentset.organization.members(); -console.log(members); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/organization/update-organization.ts b/apps/web/src/openapi/v1/organization/update-organization.ts deleted file mode 100644 index 48f944d4..00000000 --- a/apps/web/src/openapi/v1/organization/update-organization.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { - OrganizationSchema, - updateOrganizationSchema, -} from "@/schemas/api/organization"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const updateOrganization: ZodOpenApiOperationObject = { - operationId: "updateOrganization", - "x-speakeasy-name-override": "update", - summary: "Update the organization", - description: - "Update the name and/or slug of the organization associated with the API key.", - requestBody: { - required: true, - content: { - "application/json": { schema: updateOrganizationSchema }, - }, - }, - responses: { - "200": { - description: "The updated organization", - content: { - "application/json": { - schema: successSchema(OrganizationSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Organization"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const organization = await agentset.organization.update({ - name: "My Organization", -}); -console.log(organization); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/search.ts b/apps/web/src/openapi/v1/search.ts deleted file mode 100644 index eed4a200..00000000 --- a/apps/web/src/openapi/v1/search.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { - ZodOpenApiOperationObject, - ZodOpenApiPathsObject, -} from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { NodeSchema } from "@/schemas/api/node"; -import { queryVectorStoreSchema } from "@/schemas/api/query"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "./code-samples"; -import { namespaceIdPathSchema, tenantHeaderSchema } from "./utils"; - -export const search: ZodOpenApiOperationObject = { - operationId: "search", - "x-speakeasy-name-override": "execute", - "x-speakeasy-group": "search", - summary: "Search a namespace", - description: "Search a namespace for a query.", - parameters: [namespaceIdPathSchema, tenantHeaderSchema], - requestBody: { - required: true, - content: { - "application/json": { - schema: queryVectorStoreSchema, - }, - }, - }, - responses: { - "200": { - description: "The retrieved namespace", - content: { - "application/json": { - schema: successSchema(z.array(NodeSchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Search"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const results = await ns.search("What is machine learning?", { - topK: 20, - rerank: true, - rerankLimit: 10, -}); -console.log(results); -`), -}; - -export const searchPaths: ZodOpenApiPathsObject = { - "/v1/namespace/{namespaceId}/search": { - post: search, - }, -}; diff --git a/apps/web/src/openapi/v1/uploads/create-batch-upload.ts b/apps/web/src/openapi/v1/uploads/create-batch-upload.ts deleted file mode 100644 index d2071ea0..00000000 --- a/apps/web/src/openapi/v1/uploads/create-batch-upload.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { batchUploadSchema, UploadResultSchema } from "@/schemas/api/upload"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const createBatchUpload: ZodOpenApiOperationObject = { - operationId: "createBatchUpload", - "x-speakeasy-name-override": "createBatch", - summary: "Create presigned URLs for batch file upload", - description: - "Generate presigned URLs for uploading multiple files to the specified namespace.", - parameters: [namespaceIdPathSchema], - requestBody: { - required: true, - content: { - "application/json": { - schema: batchUploadSchema, - }, - }, - }, - responses: { - "201": { - description: "Presigned URLs generated successfully", - content: { - "application/json": { - schema: successSchema(z.array(UploadResultSchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Uploads"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const results = await ns.uploads.uploadBatch([ - { - file: fs.createReadStream("./example-1.md"), - contentType: "text/markdown", - }, - { - file: fs.createReadStream("./example-2.md"), - contentType: "text/markdown", - }, -]); -console.log("Uploaded successfully: ", results.map((result) => result.key)); - -// OR get the pre-signed URLs manually -const file1 = fs.readFileSync("./example-1.md"); -const file2 = fs.readFileSync("./example-2.md"); - -const results = await ns.uploads.createBatch({ - files: [ - { - fileName: "example-1.md", - fileSize: file1.length, - contentType: "text/markdown", - }, - { - fileName: "example-2.md", - fileSize: file2.length, - contentType: "text/markdown", - }, - ], -}); - -await Promise.all([file1, file2].map(async (file, i) => { - await fetch(results[i]!.url, { - method: "PUT", - body: file, - headers: { - "Content-Type": "text/markdown", - }, - }); -})); - -console.log("Upload URLs:", results.map((result) => result.key)); -`), -}; diff --git a/apps/web/src/openapi/v1/uploads/create-upload.ts b/apps/web/src/openapi/v1/uploads/create-upload.ts deleted file mode 100644 index 2bdc0789..00000000 --- a/apps/web/src/openapi/v1/uploads/create-upload.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { uploadFileSchema, UploadResultSchema } from "@/schemas/api/upload"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { namespaceIdPathSchema } from "../utils"; - -export const createUpload: ZodOpenApiOperationObject = { - operationId: "createUpload", - "x-speakeasy-name-override": "create", - summary: "Create presigned URL for file upload", - description: - "Generate a presigned URL for uploading a single file to the specified namespace.", - parameters: [namespaceIdPathSchema], - requestBody: { - required: true, - content: { - "application/json": { - schema: uploadFileSchema, - }, - }, - }, - responses: { - "201": { - description: "Presigned URL generated successfully", - content: { - "application/json": { - schema: successSchema(UploadResultSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Uploads"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -const result = await ns.uploads.upload({ - file: fs.createReadStream("./example.md"), - contentType: "text/markdown", -}); -console.log("Uploaded successfully: ", result.key); - -// OR get the pre-signed URL manually -const file = fs.readFileSync("./example.md"); -const result = await ns.uploads.create({ - fileName: "example.md", - fileSize: file.length, - contentType: "text/markdown", -}); - -await fetch(result.url, { - method: "PUT", - body: file, - headers: { - "Content-Type": "text/markdown", - }, -}); -console.log("Uploaded successfully: ", result.key); -`), -}; diff --git a/apps/web/src/openapi/v1/uploads/index.ts b/apps/web/src/openapi/v1/uploads/index.ts deleted file mode 100644 index a1f39abe..00000000 --- a/apps/web/src/openapi/v1/uploads/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { createBatchUpload } from "./create-batch-upload"; -import { createUpload } from "./create-upload"; - -export const uploadsPaths: ZodOpenApiPathsObject = { - "/v1/namespace/{namespaceId}/uploads": { - post: createUpload, - }, - "/v1/namespace/{namespaceId}/uploads/batch": { - post: createBatchUpload, - }, -}; diff --git a/apps/web/src/openapi/v1/warm-up.ts b/apps/web/src/openapi/v1/warm-up.ts deleted file mode 100644 index d7b8c4dc..00000000 --- a/apps/web/src/openapi/v1/warm-up.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { - ZodOpenApiOperationObject, - ZodOpenApiPathsObject, -} from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "./code-samples"; -import { namespaceIdPathSchema, tenantHeaderSchema } from "./utils"; - -export const warmUp: ZodOpenApiOperationObject = { - operationId: "warmUp", - "x-speakeasy-name-override": "warmUp", - "x-speakeasy-group": "namespace", - summary: "Warm cache for a namespace", - description: - "Pre-loads the namespace into the vector store's cache for faster query performance. Not all vector stores support this operation. Currently only Turbopuffer supports this operation.", - parameters: [namespaceIdPathSchema, tenantHeaderSchema], - responses: { - "200": { - description: "Cache warming started", - content: { - "application/json": { - schema: successSchema( - z.object({ - status: z.boolean(), - }), - ), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Namespaces"], - security: [{ token: [] }], - ...makeCodeSamples(ts` -await ns.warmUp(); -console.log("Cache warmed successfully"); -`), -}; - -export const warmUpPaths: ZodOpenApiPathsObject = { - "/v1/namespace/{namespaceId}/warm-up": { - post: warmUp, - }, -}; diff --git a/apps/web/src/openapi/v1/webhooks/create-webhook.ts b/apps/web/src/openapi/v1/webhooks/create-webhook.ts deleted file mode 100644 index e97464b0..00000000 --- a/apps/web/src/openapi/v1/webhooks/create-webhook.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { createWebhookSchema, WebhookSchema } from "@/schemas/api/webhook"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const createWebhook: ZodOpenApiOperationObject = { - operationId: "createWebhook", - "x-speakeasy-name-override": "create", - "x-speakeasy-usage-example": true, - summary: "Create a webhook.", - description: - "Create a webhook for the authenticated organization. Requires the Pro plan or above.", - requestBody: { - required: true, - content: { - "application/json": { schema: createWebhookSchema }, - }, - }, - responses: { - "201": { - description: "The created webhook", - content: { - "application/json": { - schema: successSchema(WebhookSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Webhooks"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const webhook = await agentset.webhooks.create({ - name: "My Webhook", - url: "https://example.com/webhooks/agentset", - triggers: ["document.ready", "ingest_job.ready"], - // namespaceIds: ["ns_xxx"], -}); -console.log(webhook); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/webhooks/delete-webhook.ts b/apps/web/src/openapi/v1/webhooks/delete-webhook.ts deleted file mode 100644 index 4618f63a..00000000 --- a/apps/web/src/openapi/v1/webhooks/delete-webhook.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses } from "@/openapi/responses"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { webhookIdPathSchema } from "./utils"; - -export const deleteWebhook: ZodOpenApiOperationObject = { - operationId: "deleteWebhook", - "x-speakeasy-name-override": "delete", - "x-speakeasy-max-method-params": 1, - summary: "Delete a webhook.", - description: "Delete a webhook for the authenticated organization.", - parameters: [webhookIdPathSchema], - responses: { - "204": { - description: "The webhook was deleted", - }, - ...openApiErrorResponses, - }, - tags: ["Webhooks"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -await agentset.webhooks.delete("wh_xxx"); -console.log("Webhook deleted"); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/webhooks/get-webhook.ts b/apps/web/src/openapi/v1/webhooks/get-webhook.ts deleted file mode 100644 index 4d5c1c12..00000000 --- a/apps/web/src/openapi/v1/webhooks/get-webhook.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { WebhookDetailsSchema } from "@/schemas/api/webhook"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { webhookIdPathSchema } from "./utils"; - -export const getWebhook: ZodOpenApiOperationObject = { - operationId: "getWebhook", - "x-speakeasy-name-override": "get", - summary: "Retrieve a webhook", - description: - "Retrieve the info for a webhook, including its signing secret and delivery failure stats.", - parameters: [webhookIdPathSchema], - responses: { - "200": { - description: "The retrieved webhook", - content: { - "application/json": { - schema: successSchema(WebhookDetailsSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Webhooks"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const webhook = await agentset.webhooks.get("wh_xxx"); -console.log(webhook); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/webhooks/index.ts b/apps/web/src/openapi/v1/webhooks/index.ts deleted file mode 100644 index ae3a9987..00000000 --- a/apps/web/src/openapi/v1/webhooks/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { createWebhook } from "./create-webhook"; -import { deleteWebhook } from "./delete-webhook"; -import { getWebhook } from "./get-webhook"; -import { listWebhooks } from "./list-webhooks"; -import { regenerateWebhookSecret } from "./regenerate-webhook-secret"; -import { updateWebhook } from "./update-webhook"; - -export const webhooksPaths: ZodOpenApiPathsObject = { - "/v1/webhooks": { - get: listWebhooks, - post: createWebhook, - }, - "/v1/webhooks/{webhookId}": { - get: getWebhook, - patch: updateWebhook, - delete: deleteWebhook, - }, - "/v1/webhooks/{webhookId}/regenerate-secret": { - post: regenerateWebhookSecret, - }, -}; diff --git a/apps/web/src/openapi/v1/webhooks/list-webhooks.ts b/apps/web/src/openapi/v1/webhooks/list-webhooks.ts deleted file mode 100644 index 009721e2..00000000 --- a/apps/web/src/openapi/v1/webhooks/list-webhooks.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { WebhookSummarySchema } from "@/schemas/api/webhook"; -import { z } from "zod/v4"; - -import { makeCodeSamples, ts } from "../code-samples"; - -export const listWebhooks: ZodOpenApiOperationObject = { - operationId: "listWebhooks", - "x-speakeasy-name-override": "list", - summary: "Retrieve a list of webhooks", - description: - "Retrieve a list of webhooks for the authenticated organization. The signing secret is not included, retrieve a single webhook to get it.", - responses: { - "200": { - description: "The retrieved webhooks", - content: { - "application/json": { - schema: successSchema(z.array(WebhookSummarySchema)), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Webhooks"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const webhooks = await agentset.webhooks.list(); -console.log(webhooks); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/webhooks/regenerate-webhook-secret.ts b/apps/web/src/openapi/v1/webhooks/regenerate-webhook-secret.ts deleted file mode 100644 index f1a87666..00000000 --- a/apps/web/src/openapi/v1/webhooks/regenerate-webhook-secret.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { WebhookSchema } from "@/schemas/api/webhook"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { webhookIdPathSchema } from "./utils"; - -export const regenerateWebhookSecret: ZodOpenApiOperationObject = { - operationId: "regenerateWebhookSecret", - "x-speakeasy-name-override": "regenerateSecret", - "x-speakeasy-max-method-params": 1, - summary: "Regenerate a webhook secret.", - description: - "Generate a new signing secret for a webhook. The old secret stops being used immediately.", - parameters: [webhookIdPathSchema], - responses: { - "200": { - description: "The webhook with its new secret", - content: { - "application/json": { - schema: successSchema(WebhookSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Webhooks"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const webhook = await agentset.webhooks.regenerateSecret("wh_xxx"); -console.log(webhook.secret); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/webhooks/update-webhook.ts b/apps/web/src/openapi/v1/webhooks/update-webhook.ts deleted file mode 100644 index 71c579c7..00000000 --- a/apps/web/src/openapi/v1/webhooks/update-webhook.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ZodOpenApiOperationObject } from "zod-openapi"; -import { openApiErrorResponses, successSchema } from "@/openapi/responses"; -import { updateWebhookSchema, WebhookSchema } from "@/schemas/api/webhook"; - -import { makeCodeSamples, ts } from "../code-samples"; -import { webhookIdPathSchema } from "./utils"; - -export const updateWebhook: ZodOpenApiOperationObject = { - operationId: "updateWebhook", - "x-speakeasy-name-override": "update", - "x-speakeasy-max-method-params": 2, - summary: "Update a webhook.", - description: - "Update a webhook for the authenticated organization. Field updates require the Pro plan or above; enabling/disabling a webhook does not.", - parameters: [webhookIdPathSchema], - requestBody: { - required: true, - content: { - "application/json": { schema: updateWebhookSchema }, - }, - }, - responses: { - "200": { - description: "The updated webhook", - content: { - "application/json": { - schema: successSchema(WebhookSchema), - }, - }, - }, - ...openApiErrorResponses, - }, - tags: ["Webhooks"], - security: [{ token: [] }], - ...makeCodeSamples( - ts` -const updatedWebhook = await agentset.webhooks.update("wh_xxx", { - name: "Updated Webhook", - // enabled: false, -}); -console.log(updatedWebhook); -`, - { isNs: false }, - ), -}; diff --git a/apps/web/src/openapi/v1/webhooks/utils.ts b/apps/web/src/openapi/v1/webhooks/utils.ts deleted file mode 100644 index 167cdf1c..00000000 --- a/apps/web/src/openapi/v1/webhooks/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod/v4"; - -export const webhookIdPathSchema = z.string().meta({ - examples: ["wh_123"], - description: "The id of the webhook (prefixed with wh_)", - param: { - in: "path", - name: "webhookId", - id: "WebhookIdRef", - }, -}); diff --git a/apps/web/src/openapi/v1/utils.ts b/apps/web/src/schemas/api/params.ts similarity index 64% rename from apps/web/src/openapi/v1/utils.ts rename to apps/web/src/schemas/api/params.ts index 4f679f2f..2d02941b 100644 --- a/apps/web/src/openapi/v1/utils.ts +++ b/apps/web/src/schemas/api/params.ts @@ -1,6 +1,12 @@ import { z } from "zod/v4"; const tenantIdRegex = /^[A-Za-z0-9]{1,64}$/; + +/** + * The optional `x-tenant-id` request header. In the OpenAPI document it is + * published as the shared `#/components/parameters/TenantIdRef` component + * (see `@/server/orpc/spec`). + */ export const tenantHeaderSchema = z .string() .regex(tenantIdRegex) @@ -8,40 +14,24 @@ export const tenantHeaderSchema = z .meta({ description: "Optional tenant id to use for the request. If not provided, the namespace will be used directly. Must be alphanumeric and up to 64 characters.", - param: { - in: "header", - name: "x-tenant-id", - id: "TenantIdRef", - }, }); +/** + * Path-parameter schemas shared by the public v1 procedures. The generated + * inline parameters are swapped for `#/components/parameters/*Ref` component + * references by the spec post-processing (see `@/server/orpc/spec`). + */ export const namespaceIdPathSchema = z.string().meta({ examples: ["ns_123"], description: "The id of the namespace (prefixed with ns_)", - param: { - in: "path", - name: "namespaceId", - id: "NamespaceIdRef", - "x-speakeasy-globals-hidden": true, - }, }); export const documentIdPathSchema = z.string().meta({ examples: ["doc_123"], - param: { - in: "path", - name: "documentId", - id: "DocumentIdRef", - }, description: "The id of the document (prefixed with doc_)", }); export const jobIdPathSchema = z.string().meta({ examples: ["job_123"], description: "The id of the job (prefixed with job_)", - param: { - in: "path", - name: "jobId", - id: "JobIdRef", - }, }); diff --git a/apps/web/src/server/orpc/base.ts b/apps/web/src/server/orpc/base.ts index 81600e87..b10fc4ce 100644 --- a/apps/web/src/server/orpc/base.ts +++ b/apps/web/src/server/orpc/base.ts @@ -8,11 +8,11 @@ import { } from "@/lib/api/errors"; import { getNamespace } from "@/lib/api/handler/namespace"; import { ratelimit } from "@/lib/api/rate-limit"; -import { tenantHeaderSchema } from "@/openapi/v1/utils"; +import { tenantHeaderSchema } from "@/schemas/api/params"; import { ORPCError, os, ValidationError } from "@orpc/server"; import { z, ZodError } from "zod/v4"; -import type { Namespace, Organization } from "@agentset/db"; +import type { Organization } from "@agentset/db"; import { normalizeId, tryCatch } from "@agentset/utils"; /** @@ -146,7 +146,7 @@ export const publicApi = publicBase.use(apiKeyAuthMiddleware); export const requireNamespace = os .$context() .middleware(async ({ context, next }, namespaceIdInput: string) => { - const namespaceId = normalizeId(namespaceIdInput ?? "", "ns_"); + const namespaceId = normalizeId(namespaceIdInput, "ns_"); if (!namespaceId) { throw new AgentsetApiError({ code: "bad_request", @@ -168,7 +168,7 @@ export const requireNamespace = os context.analytics.namespaceId = namespace.id; - return next({ context: { namespace: namespace as Namespace } }); + return next({ context: { namespace } }); }); /** Success envelope — the response schema of every enveloped v1 endpoint. */ diff --git a/apps/web/src/server/orpc/internal/billing.ts b/apps/web/src/server/orpc/internal/billing.ts index 4b8bcaf1..f01147c2 100644 --- a/apps/web/src/server/orpc/internal/billing.ts +++ b/apps/web/src/server/orpc/internal/billing.ts @@ -280,8 +280,10 @@ export const billingRouter = { return_url: `${getBaseUrl()}/${context.organization.slug}/billing`, }); return url; + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new ORPCError("BAD_REQUEST", { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access message: error?.raw?.message, }); } @@ -385,8 +387,10 @@ export const billingRouter = { }, }); return url; + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { throw new ORPCError("BAD_REQUEST", { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access message: error.raw.message, }); } diff --git a/apps/web/src/server/orpc/public/chat.ts b/apps/web/src/server/orpc/public/chat.ts index cd45e632..d10b0166 100644 --- a/apps/web/src/server/orpc/public/chat.ts +++ b/apps/web/src/server/orpc/public/chat.ts @@ -3,13 +3,9 @@ import { incrementOrganizationSearchUsage, } from "@/lib/api/usage"; import { generateChat, streamChat } from "@/lib/chat"; -import { namespaceIdPathSchema } from "@/openapi/v1/utils"; import { chatResponseSchema, chatSchema } from "@/schemas/api/chat"; -import { - publicApi, - requireNamespace, - successSchema, -} from "@/server/orpc/base"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; +import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; import { toModelMessages } from "@/services/chat"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { z } from "zod/v4"; diff --git a/apps/web/src/server/orpc/public/documents.ts b/apps/web/src/server/orpc/public/documents.ts index b1d727c6..5e653038 100644 --- a/apps/web/src/server/orpc/public/documents.ts +++ b/apps/web/src/server/orpc/public/documents.ts @@ -1,7 +1,7 @@ import { documentIdPathSchema, namespaceIdPathSchema, -} from "@/openapi/v1/utils"; +} from "@/schemas/api/params"; import { DocumentSchema, getDocumentsSchema } from "@/schemas/api/document"; import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; import { queueDocumentDeletion } from "@/services/documents/delete"; diff --git a/apps/web/src/server/orpc/public/hosting.ts b/apps/web/src/server/orpc/public/hosting.ts index 188e8a9b..b7ad8aaa 100644 --- a/apps/web/src/server/orpc/public/hosting.ts +++ b/apps/web/src/server/orpc/public/hosting.ts @@ -1,5 +1,5 @@ import { AgentsetApiError } from "@/lib/api/errors"; -import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; import { addDomainSchema, DomainSchema, diff --git a/apps/web/src/server/orpc/public/ingest-jobs.ts b/apps/web/src/server/orpc/public/ingest-jobs.ts index ce224f44..24117a25 100644 --- a/apps/web/src/server/orpc/public/ingest-jobs.ts +++ b/apps/web/src/server/orpc/public/ingest-jobs.ts @@ -1,10 +1,10 @@ import { AgentsetApiError } from "@/lib/api/errors"; -import { jobIdPathSchema, namespaceIdPathSchema } from "@/openapi/v1/utils"; import { createIngestJobSchema, getIngestionJobsSchema, IngestJobSchema, } from "@/schemas/api/ingest-job"; +import { jobIdPathSchema, namespaceIdPathSchema } from "@/schemas/api/params"; import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; import { createIngestJob } from "@/services/ingest-jobs/create"; import { deleteIngestJob } from "@/services/ingest-jobs/delete"; @@ -227,30 +227,20 @@ const del = publicApi summary: "Delete an ingest job", description: "Delete an ingest job for the authenticated organization.", tags: ["Ingest Jobs"], - spec: (current) => { - // The legacy spec documented this soft delete under "204" even though - // the route responds 200 with the queued-for-delete record. Keep the - // published document byte-compatible without changing wire behavior. - const { "200": okResponse, ...restResponses } = current.responses ?? {}; - - return { - ...current, - ...(okResponse && { - responses: { - "204": okResponse, - ...restResponses, - }, - }), - "x-speakeasy-name-override": "delete", - "x-speakeasy-group": "ingestJobs", - "x-speakeasy-max-method-params": 1, - security: [{ token: [] }], - ...makeCodeSamples(ts` + // The legacy spec documents this soft delete under "204" even though the + // route responds 200 with the queued-for-delete record; the central spec + // post-processing renames the response key (see `@/server/orpc/spec`). + spec: (current) => ({ + ...current, + "x-speakeasy-name-override": "delete", + "x-speakeasy-group": "ingestJobs", + "x-speakeasy-max-method-params": 1, + security: [{ token: [] }], + ...makeCodeSamples(ts` await ns.ingestion.delete("job_123"); console.log("Ingest job queued for deletion"); `), - }; - }, + }), }) .input( z.object({ diff --git a/apps/web/src/server/orpc/public/namespaces.ts b/apps/web/src/server/orpc/public/namespaces.ts index 7fccfa06..df9c8f06 100644 --- a/apps/web/src/server/orpc/public/namespaces.ts +++ b/apps/web/src/server/orpc/public/namespaces.ts @@ -1,4 +1,4 @@ -import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; import { createNamespaceSchema, NamespaceSchema, @@ -131,7 +131,7 @@ console.log(namespace); .input(z.object({ namespaceId: namespaceIdPathSchema })) .use(requireNamespace, (input) => input.namespaceId) .output(successSchema(NamespaceSchema)) - .handler(async ({ context }) => { + .handler(({ context }) => { return { success: true as const, data: { diff --git a/apps/web/src/server/orpc/public/search.ts b/apps/web/src/server/orpc/public/search.ts index 3b147a32..cfa06495 100644 --- a/apps/web/src/server/orpc/public/search.ts +++ b/apps/web/src/server/orpc/public/search.ts @@ -2,14 +2,10 @@ import { checkSearchLimit, incrementOrganizationSearchUsage, } from "@/lib/api/usage"; -import { namespaceIdPathSchema } from "@/openapi/v1/utils"; import { NodeSchema } from "@/schemas/api/node"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; import { queryVectorStoreSchema } from "@/schemas/api/query"; -import { - publicApi, - requireNamespace, - successSchema, -} from "@/server/orpc/base"; +import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; import { searchNamespace } from "@/services/search"; import { toOpenAPISchema } from "@orpc/openapi"; import { type } from "@orpc/server"; diff --git a/apps/web/src/server/orpc/public/uploads.ts b/apps/web/src/server/orpc/public/uploads.ts index 42511003..834f3eec 100644 --- a/apps/web/src/server/orpc/public/uploads.ts +++ b/apps/web/src/server/orpc/public/uploads.ts @@ -1,5 +1,5 @@ import { AgentsetApiError } from "@/lib/api/errors"; -import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; import { batchUploadSchema, uploadFileSchema, diff --git a/apps/web/src/server/orpc/public/warm-up.ts b/apps/web/src/server/orpc/public/warm-up.ts index ca1a864f..b0edc247 100644 --- a/apps/web/src/server/orpc/public/warm-up.ts +++ b/apps/web/src/server/orpc/public/warm-up.ts @@ -1,5 +1,5 @@ import { AgentsetApiError } from "@/lib/api/errors"; -import { namespaceIdPathSchema } from "@/openapi/v1/utils"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; import { publicApi, requireNamespace } from "@/server/orpc/base"; import { type } from "@orpc/server"; import { z } from "zod/v4"; diff --git a/apps/web/src/server/orpc/spec.ts b/apps/web/src/server/orpc/spec.ts new file mode 100644 index 00000000..fa0cd02d --- /dev/null +++ b/apps/web/src/server/orpc/spec.ts @@ -0,0 +1,954 @@ +import { errorSchemaFactory } from "@/lib/api/errors"; +import { ApiKeySchema, CreatedApiKeySchema } from "@/schemas/api/api-key"; +import { chatMessageSchema, chatResponseSchema } from "@/schemas/api/chat"; +import { DocumentSchema } from "@/schemas/api/document"; +import { + DomainSchema, + DomainStatusSchema, + HostingSchema, +} from "@/schemas/api/hosting"; +import { IngestJobSchema } from "@/schemas/api/ingest-job"; +import { NamespaceSchema } from "@/schemas/api/namespace"; +import { NodeSchema } from "@/schemas/api/node"; +import { + OrganizationInvitationSchema, + OrganizationMemberSchema, + OrganizationMembersSchema, + OrganizationSchema, +} from "@/schemas/api/organization"; +import { paginationSchema } from "@/schemas/api/pagination"; +import { uploadFileSchema, UploadResultSchema } from "@/schemas/api/upload"; +import { + WebhookDetailsSchema, + WebhookSchema, + WebhookSummarySchema, +} from "@/schemas/api/webhook"; +import { OpenAPIGenerator } from "@orpc/openapi"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { z } from "zod/v4"; + +import { DocumentStatusSchema, IngestJobStatusSchema } from "@agentset/db"; +import { + AzureEmbeddingConfigSchema, + batchPayloadInputSchema, + batchPayloadSchema, + configSchema, + crawlPayloadSchema, + createVectorStoreSchema, + documentConfigSchema, + EmbeddingConfigSchema, + filePayloadSchema, + GoogleEmbeddingConfigSchema, + googleEmbeddingModelEnum, + ingestJobPayloadInputSchema, + ingestJobPayloadSchema, + languageCode, + managedFilePayloadSchema, + OpenAIEmbeddingConfigSchema, + openaiEmbeddingModelEnum, + PineconeVectorStoreConfigSchema, + textPayloadInputSchema, + textPayloadSchema, + TurbopufferVectorStoreConfigSchema, + VectorStoreSchema, + VoyageEmbeddingConfigSchema, + voyageEmbeddingModelEnum, + youtubePayloadSchema, +} from "@agentset/validation"; +import { webhookEventSchema } from "@agentset/webhooks"; + +import { successSchema } from "./base"; +import { publicRouter } from "./public/router"; + +/** + * Builds the public OpenAPI document (`/openapi.json`) from the oRPC public + * router, then post-processes it into byte-level parity with the legacy + * zod-openapi document. Speakeasy (SDK generation) and Mintlify (docs) ingest + * this document, so operationIds, `x-speakeasy-*` extensions, component + * schema NAMES and the shared error/parameter components must not drift. + */ + +/* ------------------------------------------------------------------------- + * Top-level document metadata — copied verbatim from the legacy + * `src/openapi/index.ts`. + * ---------------------------------------------------------------------- */ + +const INFO = { + title: "AgentsetAPI", + description: "Agentset is agentic rag-as-a-service", + version: "0.0.1", + contact: { + name: "Agentset Support", + email: "support@agentset.ai", + url: "https://api.agentset.ai/", + }, + license: { + name: "MIT License", + url: "https://github.com/agentset-ai/agentset/blob/main/LICENSE.md", + }, +}; + +const SERVERS = [ + { + url: "https://api.agentset.ai", + description: "Production API", + }, +]; + +const SPEAKEASY_GLOBALS = { + parameters: [ + { $ref: "#/components/parameters/NamespaceIdRef" }, + { $ref: "#/components/parameters/TenantIdRef" }, + ], +}; + +const SECURITY_SCHEMES = { + token: { + type: "http", + description: "Default authentication mechanism", + scheme: "bearer", + "x-speakeasy-example": "AGENTSET_API_KEY", + }, +}; + +/** + * Shared parameter components. The legacy document emitted these from + * zod-openapi `param.id` metas; they are constants now, and every operation's + * inline path/header parameters are swapped for `$ref`s below. + */ +const PARAMETER_COMPONENTS = { + NamespaceIdRef: { + in: "path", + name: "namespaceId", + schema: { + type: "string", + examples: ["ns_123"], + description: "The id of the namespace (prefixed with ns_)", + }, + "x-speakeasy-globals-hidden": true, + required: true, + description: "The id of the namespace (prefixed with ns_)", + }, + TenantIdRef: { + in: "header", + name: "x-tenant-id", + schema: { + description: + "Optional tenant id to use for the request. If not provided, the namespace will be used directly. Must be alphanumeric and up to 64 characters.", + type: "string", + pattern: "^[A-Za-z0-9]{1,64}$", + }, + description: + "Optional tenant id to use for the request. If not provided, the namespace will be used directly. Must be alphanumeric and up to 64 characters.", + }, + JobIdRef: { + in: "path", + name: "jobId", + schema: { + type: "string", + examples: ["job_123"], + description: "The id of the job (prefixed with job_)", + }, + required: true, + description: "The id of the job (prefixed with job_)", + }, + DocumentIdRef: { + in: "path", + name: "documentId", + schema: { + type: "string", + examples: ["doc_123"], + description: "The id of the document (prefixed with doc_)", + }, + required: true, + description: "The id of the document (prefixed with doc_)", + }, + WebhookIdRef: { + in: "path", + name: "webhookId", + schema: { + type: "string", + examples: ["wh_123"], + description: "The id of the webhook (prefixed with wh_)", + }, + required: true, + description: "The id of the webhook (prefixed with wh_)", + }, + ApiKeyIdRef: { + in: "path", + name: "keyId", + schema: { + type: "string", + examples: ["cm4x1q2z90000abcd1234efgh"], + description: "The id of the API key.", + }, + required: true, + description: "The id of the API key.", + }, +}; + +/** Path-parameter name → shared parameter component. */ +const PATH_PARAM_COMPONENTS: Record = { + namespaceId: "NamespaceIdRef", + documentId: "DocumentIdRef", + jobId: "JobIdRef", + webhookId: "WebhookIdRef", + keyId: "ApiKeyIdRef", +}; + +/** Operations that accept the `x-tenant-id` header (matches the old spec). */ +const TENANT_HEADER_OPERATIONS = new Set([ + "listIngestJobs", + "createIngestJob", + "getIngestJobInfo", + "deleteIngestJob", + "reIngestJob", + "listDocuments", + "getDocument", + "deleteDocument", + "search", + "chat", + "warmUp", +]); + +/* ------------------------------------------------------------------------- + * Shared error responses (`components.responses`), $ref'd from every + * operation. Descriptions copied verbatim from the legacy zod-openapi + * responses module; the zod-openapi-specific `id` is dropped — the component + * key is the status string. + * ---------------------------------------------------------------------- */ + +const ERROR_RESPONSE_DESCRIPTIONS = { + bad_request: + "The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).", + unauthorized: `Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response.`, + forbidden: + "The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server.", + not_found: "The server cannot find the requested resource.", + conflict: + "This response is sent when a request conflicts with the current state of the server.", + invite_expired: + "This response is sent when the requested content has been permanently deleted from server, with no forwarding address.", + unprocessable_entity: + "The request was well-formed but was unable to be followed due to semantic errors.", + rate_limit_exceeded: `The user has sent too many requests in a given amount of time ("rate limiting")`, + internal_server_error: + "The server has encountered a situation it does not know how to handle.", +} as const; + +const ERROR_RESPONSE_COMPONENTS = Object.fromEntries( + Object.entries(ERROR_RESPONSE_DESCRIPTIONS).map(([code, description]) => { + const { id, ...response } = errorSchemaFactory( + code as keyof typeof ERROR_RESPONSE_DESCRIPTIONS, + description, + ); + return [id, response]; + }), +); + +const ERROR_RESPONSE_REFS = Object.fromEntries( + Object.keys(ERROR_RESPONSE_COMPONENTS).map((status) => [ + status, + { $ref: `#/components/responses/${status}` }, + ]), +); + +/* ------------------------------------------------------------------------- + * Success-response descriptions per operation (the raw generator emits + * "OK"). Copied verbatim from the legacy per-operation spec files. + * ---------------------------------------------------------------------- */ + +const SUCCESS_DESCRIPTIONS: Record = { + listNamespaces: "The retrieved namespaces", + createNamespace: "The created namespace", + getNamespace: "The retrieved namespace", + updateNamespace: "The updated namespace", + deleteNamespace: "The namespace was queued for deletion.", + listIngestJobs: "The retrieved ingest jobs", + createIngestJob: "The created ingest job", + getIngestJobInfo: "The retrieved ingest job", + deleteIngestJob: "The deleted ingest job", + reIngestJob: "The re-ingested job", + // NOTE: the "ingest job" wording on the two document operations is a legacy + // copy/paste quirk — kept verbatim for parity. + listDocuments: "The retrieved ingest jobs", + getDocument: "The retrieved ingest job", + deleteDocument: "The deleted document", + getChunksDownloadUrl: "The presigned download URL for the chunks", + getFileDownloadUrl: "The presigned download URL for the file", + search: "The retrieved namespace", + chat: "The generated answer and the sources used to generate it", + warmUp: "Cache warming started", + createUpload: "Presigned URL generated successfully", + createBatchUpload: "Presigned URLs generated successfully", + getHosting: "The hosting configuration", + enableHosting: "The created hosting configuration", + updateHosting: "The updated hosting configuration", + deleteHosting: "The hosting configuration was deleted", + checkDomainStatus: "The domain status", + addDomain: "The added domain", + removeDomain: "The domain was removed", + listWebhooks: "The retrieved webhooks", + createWebhook: "The created webhook", + getWebhook: "The retrieved webhook", + updateWebhook: "The updated webhook", + deleteWebhook: "The webhook was deleted", + regenerateWebhookSecret: "The webhook with its new secret", + getOrganization: "The retrieved organization", + updateOrganization: "The updated organization", + listOrganizationMembers: "The retrieved members and pending invitations", + listApiKeys: "The retrieved API keys", + createApiKey: "The created API key", + deleteApiKey: "The API key was deleted", +}; + +/** + * Soft deletes respond 200 on the wire but the legacy spec documented them + * under "204" — Speakeasy consumed that shape, so the key is renamed. + */ +const SOFT_DELETE_STATUS_RENAMES: Record = { + deleteDocument: ["200", "204"], + deleteIngestJob: ["200", "204"], +}; + +/** + * Hard deletes return an empty 204; the legacy spec documented them with a + * bare description (no content). The generator emits a `z.void()` body stub. + */ +const EMPTY_204_OPERATIONS = new Set([ + "deleteNamespace", + "deleteHosting", + "removeDomain", + "deleteWebhook", + "deleteApiKey", +]); + +/** + * Request bodies that were published as a top-level component `$ref`. The + * oRPC input schema is extended with `namespaceId`, so identity-based + * hoisting can't reproduce the root ref — swapped here instead. + */ +const REQUEST_BODY_COMPONENT_REFS: Record = { + createUpload: "upload-file-schema", +}; + +/** + * zod-openapi hoisted the `pagination-per-page` component from the schema + * before `.min().max()` were applied, leaving the numeric bounds (and type/ + * description) as `$ref` siblings at each parameter site. Reproduced + * verbatim so the published parameter shape doesn't move. + */ +const PER_PAGE_REF = "#/components/schemas/pagination-per-page"; +const PER_PAGE_PARAM_SIBLINGS = { + type: "number", + minimum: 1, + maximum: 100, + description: "The number of records to return per page.", +}; + +/* ------------------------------------------------------------------------- + * components.schemas — recreate the legacy component names by registering + * the same zod schemas the legacy spec tagged with `.meta({ id })`. + * ---------------------------------------------------------------------- */ + +/** Unwraps optional/default/nullable wrappers to the meta-carrying schema. */ +const unwrapField = (schema: z.ZodType): z.ZodType => { + let current = schema; + for (;;) { + const def = ( + current as unknown as { def: { type: string; innerType?: z.ZodType } } + ).def; + if ( + def.innerType && + (def.type === "optional" || + def.type === "default" || + def.type === "nullable") + ) { + current = def.innerType; + continue; + } + return current; + } +}; + +const modeSchema = unwrapField(configSchema.shape.mode); +const paginationCursorSchema = unwrapField(paginationSchema.shape.cursor); +const paginationCursorDirectionSchema = unwrapField( + paginationSchema.shape.cursorDirection, +); +const paginationPerPageSchema = unwrapField(paginationSchema.shape.perPage); + +const [documentWebhookEventSchema, ingestJobWebhookEventSchema] = + webhookEventSchema.def.options as unknown as [z.ZodType, z.ZodType]; + +/** + * Schemas whose legacy spec had distinct input ("X") and output ("XOutput") + * component variants (zod-openapi emitted both because its output conversion + * added `additionalProperties: false`). The same zod object backs both names: + * "X" is registered for the input strategy and a lightweight clone (same + * conversion output, distinct identity) is registered as "XOutput" for the + * output strategy; a converter interceptor routes output-context usages to + * the clone so responses keep referencing "XOutput". + */ +const IO_PAIR_SCHEMAS: Record = { + "document-config": documentConfigSchema, + "ingest-job-config": configSchema, + "embedding-model-config": EmbeddingConfigSchema, + "openai-embedding-config": OpenAIEmbeddingConfigSchema, + "azure-embedding-config": AzureEmbeddingConfigSchema, + "google-embedding-config": GoogleEmbeddingConfigSchema, + "voyage-embedding-config": VoyageEmbeddingConfigSchema, + "pinecone-config": PineconeVectorStoreConfigSchema, + "turbopuffer-config": TurbopufferVectorStoreConfigSchema, + "file-payload": filePayloadSchema, + "managed-file-payload": managedFilePayloadSchema, + "crawl-payload": crawlPayloadSchema, + "youtube-payload": youtubePayloadSchema, +}; + +/** canonical schema → its "XOutput" clone (see IO_PAIR_SCHEMAS). */ +const outputVariants = new Map( + Object.entries(IO_PAIR_SCHEMAS).map(([name, schema]) => [ + schema, + { + name: `${name}Output`, + clone: schema.meta({ ...z.globalRegistry.get(schema) }), + }, + ]), +); + +/** + * Canonical enum schemas that also appear in the conversion tree as + * `.describe()` clones (the clone is a different object, so identity + * matching misses it, but it shares the canonical's `def.entries` object). + * The interceptor redirects clones to the canonical object and keeps the + * site-specific description as a `$ref` sibling — exactly what zod-openapi + * produced. + */ +const ENUM_CLONE_CANONICALS = [ + languageCode, + DocumentStatusSchema, + IngestJobStatusSchema, +]; + +const zodDef = (schema: unknown) => + ( + schema as { + _zod: { + def: { + type: string; + entries?: unknown; + discriminator?: string; + options?: unknown[]; + out?: unknown; + shape?: Record; + values?: unknown[]; + }; + }; + } + )._zod.def; + +const enumCanonicalsByEntries = new Map( + ENUM_CLONE_CANONICALS.map((schema) => [zodDef(schema).entries, schema]), +); + +type CommonSchemaEntry = { schema: z.ZodType; strategy?: "input" | "output" }; + +const commonSchemas: Record = { + // ---- input-context components (request bodies / query parameters) ---- + "chat-message": { schema: chatMessageSchema }, + "create-vector-store-config": { schema: createVectorStoreSchema }, + "upload-file-schema": { schema: uploadFileSchema }, + "ingest-job-payload-input": { schema: ingestJobPayloadInputSchema }, + "batch-payload-input": { schema: batchPayloadInputSchema }, + "text-payload-input": { schema: textPayloadInputSchema }, + "pagination-cursor": { schema: paginationCursorSchema }, + "pagination-cursor-direction": { schema: paginationCursorDirectionSchema }, + "pagination-per-page": { schema: paginationPerPageSchema }, + + // ---- shared enums (input/output conversions are identical) ---- + "document-status": { schema: DocumentStatusSchema }, + "ingest-job-status": { schema: IngestJobStatusSchema }, + "language-code": { schema: languageCode }, + mode: { schema: modeSchema }, + "openai-embedding-model-enum": { schema: openaiEmbeddingModelEnum }, + "google-embedding-model-enum": { schema: googleEmbeddingModelEnum }, + "voyage-embedding-model-enum": { schema: voyageEmbeddingModelEnum }, + // regionEnum is not re-exported from the validation barrel; the shape field + // is the same object. + "turbopuffer-region-enum": { + schema: TurbopufferVectorStoreConfigSchema.shape.region, + }, + + // ---- output-context components (responses) ---- + "api-key": { schema: ApiKeySchema, strategy: "output" }, + "created-api-key": { schema: CreatedApiKeySchema, strategy: "output" }, + "chat-response": { schema: chatResponseSchema, strategy: "output" }, + document: { schema: DocumentSchema, strategy: "output" }, + domain: { schema: DomainSchema, strategy: "output" }, + "domain-status": { schema: DomainStatusSchema, strategy: "output" }, + hosting: { schema: HostingSchema, strategy: "output" }, + "ingest-job": { schema: IngestJobSchema, strategy: "output" }, + namespace: { schema: NamespaceSchema, strategy: "output" }, + organization: { schema: OrganizationSchema, strategy: "output" }, + "organization-member": { + schema: OrganizationMemberSchema, + strategy: "output", + }, + "organization-invitation": { + schema: OrganizationInvitationSchema, + strategy: "output", + }, + "organization-members": { + schema: OrganizationMembersSchema, + strategy: "output", + }, + "upload-result-schema": { schema: UploadResultSchema, strategy: "output" }, + webhook: { schema: WebhookSchema, strategy: "output" }, + "webhook-summary": { schema: WebhookSummarySchema, strategy: "output" }, + "webhook-details": { schema: WebhookDetailsSchema, strategy: "output" }, + "ingest-job-payload": { schema: ingestJobPayloadSchema, strategy: "output" }, + "batch-payload": { schema: batchPayloadSchema, strategy: "output" }, + "text-payload": { schema: textPayloadSchema, strategy: "output" }, + "vector-store-config": { schema: VectorStoreSchema, strategy: "output" }, + + // ---- standalone webhook event schemas (kept for the SDK via + // x-speakeasy-include; not referenced by any operation) ---- + WebhookEvent: { schema: webhookEventSchema }, + DocumentWebhookEvent: { schema: documentWebhookEventSchema }, + IngestJobWebhookEvent: { schema: ingestJobWebhookEventSchema }, +}; + +// the input↔output pairs +for (const [name, schema] of Object.entries(IO_PAIR_SCHEMAS)) { + commonSchemas[name] = { schema }; + const variant = outputVariants.get(schema); + if (variant) { + commonSchemas[variant.name] = { schema: variant.clone, strategy: "output" }; + } +} + +/* ------------------------------------------------------------------------- + * Converter — interceptors patch the two places where plain identity-based + * hoisting diverges from the legacy zod-openapi wiring. + * ---------------------------------------------------------------------- */ + +type ConverterInterceptor = NonNullable< + ConstructorParameters[0] +>["interceptors"] extends (infer T)[] | undefined + ? T + : never; + +/** Output-context usages of the IO pairs must reference "XOutput". */ +const outputVariantInterceptor: ConverterInterceptor = (options) => { + const variant = outputVariants.get(options.schema as z.ZodType); + if (variant && options.options.strategy === "output") { + return options.next({ ...options, schema: variant.clone as never }); + } + return options.next(); +}; + +/** `.describe()` clones of shared enums → `$ref` + sibling description. */ +const enumCloneInterceptor: ConverterInterceptor = (options) => { + const schema = options.schema as z.ZodType; + const def = zodDef(schema); + if (def.type === "enum") { + const canonical = enumCanonicalsByEntries.get(def.entries); + if (canonical && canonical !== schema) { + const [required, json] = options.next({ + ...options, + schema: canonical as never, + }); + const description = z.globalRegistry.get(schema)?.description; + return [ + required, + typeof description === "string" ? { description, ...json } : json, + ]; + } + } + return options.next(); +}; + +/** + * zod-openapi passed every zod `.meta()` key through into the JSON schema + * (`deprecated`, `x-speakeasy-deprecation-message`, `example`, `style`, ...); + * the oRPC converter only lifts title/description/examples. + */ +const HANDLED_META_KEYS = new Set([ + "id", + "outputId", + "title", + "description", + "examples", +]); + +const metaPassthroughInterceptor: ConverterInterceptor = (options) => { + const meta = z.globalRegistry.get(options.schema as z.ZodType); + const extras = meta + ? Object.entries(meta).filter( + ([key, value]) => !HANDLED_META_KEYS.has(key) && value !== undefined, + ) + : []; + if (extras.length === 0) return options.next(); + const [required, json] = options.next(); + return [required, { ...json, ...Object.fromEntries(extras) }]; +}; + +/** + * Structural conversion differences vs the legacy zod-openapi output: + * - discriminated unions were emitted as `{ type: "object", oneOf }`, plus a + * `discriminator` mapping when every member is a component `$ref`; + * - output-context objects carried `additionalProperties: false`; + * - output-context defaulted fields (and `.transform()`ed pipes) were + * required — the value is always present in a response. + */ +const legacyParityInterceptor: ConverterInterceptor = (options) => { + const next = () => options.next(); + const def = zodDef(options.schema); + const strategy = options.options.strategy; + + if (def.type === "union" && def.discriminator) { + const [required, json] = next(); + if (Array.isArray(json.anyOf) && !json.oneOf) { + const { anyOf, ...rest } = json; + const result: Record = { + type: "object", + oneOf: anyOf, + ...rest, + }; + const mapping = discriminatorMapping(def, anyOf); + if (mapping) { + result.discriminator = { + propertyName: def.discriminator, + mapping, + }; + } + return [required, result as typeof json]; + } + return [required, json]; + } + + if (strategy === "output") { + if (def.type === "default" || def.type === "prefault") { + const [, json] = next(); + return [true, json]; + } + if (def.type === "pipe" && zodDef(def.out).type === "transform") { + const [, json] = next(); + return [true, json]; + } + if (def.type === "object") { + const [required, json] = next(); + if (json.type === "object" && json.additionalProperties === undefined) { + json.additionalProperties = false; + } + return [required, json]; + } + } + + if (def.type === "string") { + // zod-openapi published `z.email()` with its regex pattern alongside the + // format; the oRPC converter emits the format only + const bag = ( + options.schema as unknown as { + _zod: { bag?: { format?: string; patterns?: Set } }; + } + )._zod.bag; + if (bag?.format === "email" && bag.patterns?.size) { + const [required, json] = next(); + if (json.format === "email" && json.pattern === undefined) { + json.pattern = [...bag.patterns][0]!.source; + } + return [required, json]; + } + } + + return next(); +}; + +/** `discriminator.mapping` for all-`$ref` unions (mirrors zod-openapi). */ +const discriminatorMapping = ( + def: ReturnType, + members: unknown[], +): Record | undefined => { + const options = def.options ?? []; + if (members.length !== options.length) return undefined; + + const mapping: Record = {}; + for (const [index, member] of members.entries()) { + if (!isJsonObject(member) || typeof member.$ref !== "string") { + return undefined; + } + const discriminatorField = def.discriminator + ? zodDef(options[index]).shape?.[def.discriminator] + : undefined; + const values = discriminatorField + ? zodDef(discriminatorField).values + : undefined; + const value = Array.isArray(values) ? values[0] : undefined; + if (typeof value !== "string") return undefined; + mapping[value] = member.$ref; + } + return mapping; +}; + +const schemaConverter = new ZodToJsonSchemaConverter({ + interceptors: [ + outputVariantInterceptor, + enumCloneInterceptor, + metaPassthroughInterceptor, + legacyParityInterceptor, + ], +}); + +/** + * Success-response schemas replaced wholesale. The chat and search + * procedures return unparsed data (`type<>()` passthrough outputs), so their + * resource files document the 200 body via bare-converter spec overrides — + * those can't hoist components or apply the parity interceptors. The bodies + * are rebuilt here from the same zod schemas with the shared converter. + */ +const convertOutput = ( + schema: z.ZodType, + components?: { + schema: z.ZodType; + required: boolean; + ref: string; + allowedStrategies: ("input" | "output")[]; + }[], +) => { + const [, json] = schemaConverter.convert(schema, { + strategy: "output", + components, + }); + return json as Record; +}; + +const RESPONSE_SCHEMA_OVERRIDES: Record< + string, + { status: string; schema: Record } +> = { + chat: { + status: "200", + schema: convertOutput(successSchema(chatResponseSchema), [ + { + schema: chatResponseSchema, + required: true, + ref: "#/components/schemas/chat-response", + allowedStrategies: ["output"], + }, + ]), + }, + search: { + status: "200", + schema: convertOutput(successSchema(z.array(NodeSchema))), + }, +}; + +/* ------------------------------------------------------------------------- + * Post-processing — pure, deterministic transforms over the generated + * document. + * ---------------------------------------------------------------------- */ + +type JsonObject = Record; + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Depth-first visit of every plain object in the document. */ +const walkObjects = (node: unknown, visit: (obj: JsonObject) => void) => { + if (Array.isArray(node)) { + for (const item of node) walkObjects(item, visit); + } else if (isJsonObject(node)) { + visit(node); + for (const value of Object.values(node)) walkObjects(value, visit); + } +}; + +/** + * The legacy document annotated literals with their JSON type + * (`{"type":"boolean","const":true}`); the oRPC converter omits it. + */ +const addConstTypes = (obj: JsonObject) => { + if (!("const" in obj) || "type" in obj || "$ref" in obj) return; + const constType = typeof obj.const; + if ( + constType === "string" || + constType === "number" || + constType === "boolean" + ) { + obj.type = constType; + } +}; + +/** + * Drop oRPC-only `x-native-type` annotations. `z.date()` responses were + * published as plain strings (no `format`) by the legacy spec, so the + * date-time format goes with it. + */ +const stripNativeTypes = (obj: JsonObject) => { + if (!("x-native-type" in obj)) return; + if (obj["x-native-type"] === "date" && obj.format === "date-time") { + delete obj.format; + } + delete obj["x-native-type"]; +}; + +/** oRPC parameter-serialization hints the legacy spec never carried. */ +const stripParameterNoise = (param: JsonObject) => { + delete param.allowEmptyValue; + delete param.allowReserved; + delete param.style; + delete param.explode; + + // legacy per-page parameter shape: numeric bounds as $ref siblings + if (isJsonObject(param.schema) && param.schema.$ref === PER_PAGE_REF) { + Object.assign(param.schema, PER_PAGE_PARAM_SIBLINGS); + } +}; + +const rewriteOperation = (path: string, operation: JsonObject) => { + const operationId = operation.operationId as string | undefined; + const responses = (operation.responses ?? {}) as JsonObject; + + // 1. soft deletes: rename the success key ("200" → "204", legacy quirk) + const rename = operationId && SOFT_DELETE_STATUS_RENAMES[operationId]; + if (rename && responses[rename[0]] !== undefined) { + const renamed: JsonObject = { [rename[1]]: responses[rename[0]] }; + for (const [status, response] of Object.entries(responses)) { + if (status !== rename[0]) renamed[status] = response; + } + operation.responses = renamed; + } + + // 2. hard deletes: bare "204" with no content + if (operationId && EMPTY_204_OPERATIONS.has(operationId)) { + (operation.responses as JsonObject)["204"] = { + description: SUCCESS_DESCRIPTIONS[operationId], + }; + } + + // 3. success descriptions (the generator emits "OK") + if (operationId && SUCCESS_DESCRIPTIONS[operationId]) { + for (const [status, response] of Object.entries( + operation.responses as JsonObject, + )) { + if (status.startsWith("2") && isJsonObject(response)) { + response.description = SUCCESS_DESCRIPTIONS[operationId]; + } + } + } + + // 4. shared error responses on every operation + operation.responses = { + ...(operation.responses as JsonObject), + ...ERROR_RESPONSE_REFS, + }; + + // 5. parameters: inline path params → shared $refs (ordered by their + // appearance in the path), tenant header appended last, query params + // keep their generated order but lose oRPC serialization hints + const parameters = (operation.parameters ?? []) as JsonObject[]; + const nonPathParams = parameters.filter((param) => param.in !== "path"); + for (const param of nonPathParams) stripParameterNoise(param); + + const pathParamRefs = [...path.matchAll(/\{(\w+)\}/g)] + .map((match) => PATH_PARAM_COMPONENTS[match[1] ?? ""]) + .filter((component): component is string => !!component) + .map((component) => ({ $ref: `#/components/parameters/${component}` })); + + const tenantRefs = + operationId && TENANT_HEADER_OPERATIONS.has(operationId) + ? [{ $ref: "#/components/parameters/TenantIdRef" }] + : []; + + const rewritten = [...nonPathParams, ...pathParamRefs, ...tenantRefs]; + if (rewritten.length > 0) { + operation.parameters = rewritten; + } else { + delete operation.parameters; + } + + // 6. request bodies: the legacy document marked every body required, and + // some were published as a top-level component $ref + if (isJsonObject(operation.requestBody)) { + operation.requestBody.required = true; + + const bodyRef = operationId && REQUEST_BODY_COMPONENT_REFS[operationId]; + const content = operation.requestBody.content as JsonObject | undefined; + const jsonContent = content?.["application/json"]; + if (bodyRef && isJsonObject(jsonContent)) { + jsonContent.schema = { $ref: `#/components/schemas/${bodyRef}` }; + } + } + + // 7. pinned success-response schemas + const override = operationId && RESPONSE_SCHEMA_OVERRIDES[operationId]; + if (override) { + const response = (operation.responses as JsonObject)[override.status]; + if (isJsonObject(response)) { + const content = response.content as JsonObject | undefined; + const jsonContent = content?.["application/json"]; + if (isJsonObject(jsonContent)) jsonContent.schema = override.schema; + } + } +}; + +const postProcess = (document: JsonObject): JsonObject => { + // /v1 prefix lives in the route handler (`handler.handle`), not in the + // procedures' paths — re-key so the published document keeps it. + const paths = (document.paths ?? {}) as JsonObject; + const prefixedPaths: JsonObject = Object.fromEntries( + Object.entries(paths).map(([path, item]) => [`/v1${path}`, item]), + ); + document.paths = prefixedPaths; + + for (const [path, pathItem] of Object.entries(prefixedPaths)) { + if (!isJsonObject(pathItem)) continue; + for (const operation of Object.values(pathItem)) { + if (isJsonObject(operation)) rewriteOperation(path, operation); + } + } + + document["x-speakeasy-globals"] = SPEAKEASY_GLOBALS; + + const components = (document.components ?? {}) as JsonObject; + document.components = components; + components.parameters = PARAMETER_COMPONENTS; + components.responses = ERROR_RESPONSE_COMPONENTS; + + // counterpart of PER_PAGE_PARAM_SIBLINGS: the legacy component carried the + // bare number schema, with the bounds living at the parameter sites + const schemas = components.schemas as JsonObject | undefined; + const perPage = schemas?.["pagination-per-page"]; + if (isJsonObject(perPage)) { + delete perPage.minimum; + delete perPage.maximum; + } + + walkObjects(document, (obj) => { + addConstTypes(obj); + stripNativeTypes(obj); + }); + + return document; +}; + +/* ------------------------------------------------------------------------- */ + +const generator = new OpenAPIGenerator({ + schemaConverters: [schemaConverter], +}); + +export const buildOpenApiDocument = async (): Promise => { + const document = await generator.generate(publicRouter, { + info: INFO, + servers: SERVERS, + components: { securitySchemes: SECURITY_SCHEMES as never }, + commonSchemas: commonSchemas as never, + filter: ({ contract }) => + !contract["~orpc"].route.tags?.includes("internal-alias"), + }); + + return postProcess(document as unknown as JsonObject); +}; diff --git a/bun.lock b/bun.lock index 96f50d65..09a79976 100644 --- a/bun.lock +++ b/bun.lock @@ -68,7 +68,6 @@ "virtua": "^0.48.3", "zod": "catalog:", "zod-error": "catalog:", - "zod-openapi": "catalog:", "zustand": "^5.0.10", }, "devDependencies": { @@ -3344,8 +3343,6 @@ "zod-error": ["zod-error@2.0.0", "", { "peerDependencies": { "zod": "^4.0.0" } }, "sha512-ivSoPm7h5SEGG8GUvQ7YTwrnEx36qNmUIU5hSIGcqcdl7+ZFoTrYXAdQEs3KP/rkihrrR9REEdOeGU+fWdMoNw=="], - "zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="], - "zod-to-json-schema": ["zod-to-json-schema@3.24.4", "", { "peerDependencies": { "zod": "3.25.76" } }, "sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig=="], "zod-validation-error": ["zod-validation-error@3.4.0", "", { "peerDependencies": { "zod": "3.25.76" } }, "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ=="], From f2a0e137682bb486d976c3df0ce28457ba243533 Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Sat, 4 Jul 2026 12:18:00 +0200 Subject: [PATCH 07/10] Prune the dead legacy API-key wrapper code withApiHandler/withNamespaceApiHandler are fully replaced by the oRPC middleware; only HandlerParams (shared by the session/public wrappers still used by the internal chat and hosting routes) and the cached getNamespace lookup (shared with MCP) remain. --- apps/web/src/lib/api/handler/base.ts | 122 +--------------------- apps/web/src/lib/api/handler/namespace.ts | 74 ------------- 2 files changed, 5 insertions(+), 191 deletions(-) diff --git a/apps/web/src/lib/api/handler/base.ts b/apps/web/src/lib/api/handler/base.ts index 2074fa7a..f5d6adee 100644 --- a/apps/web/src/lib/api/handler/base.ts +++ b/apps/web/src/lib/api/handler/base.ts @@ -1,20 +1,14 @@ import type { NextRequest } from "next/server"; -import { - flushServerEvents, - identifyOrganization, - logServerEvent, -} from "@/lib/analytics-server"; import type { Organization } from "@agentset/db"; -import { tryCatch } from "@agentset/utils"; import type { ApiKeyInfo } from "../api-key"; -import { getApiKeyInfo } from "../api-key"; -import { AgentsetApiError, handleAndReturnErrorResponse } from "../errors"; -import { ratelimit } from "../rate-limit"; -import { getTenantFromRequest } from "../tenant"; -import { getSearchParams } from "../utils"; +/** + * Shared param shape for the remaining legacy wrappers (`withAuthApiHandler`, + * `withPublicApiHandler`). The org-API-key wrapper that used to live here was + * replaced by the oRPC middleware in `@/server/orpc/base`. + */ export interface HandlerParams { req: NextRequest; params: Record; @@ -24,109 +18,3 @@ export interface HandlerParams { tenantId?: string; headers?: Record; } - -interface Handler { - (params: HandlerParams): Promise; -} - -export const withApiHandler = ( - handler: Handler, - { logging }: { logging: false | { routeName: string } }, -) => { - return async ( - req: NextRequest, - { params }: { params: Promise | undefined> }, - ) => { - const routeParams = await params; - const searchParams = getSearchParams(req); - - let apiKey: string | undefined = undefined; - let headers = {}; - - try { - const authorizationHeader = req.headers.get("Authorization"); - if (authorizationHeader) { - if (!authorizationHeader.includes("Bearer ")) { - throw new AgentsetApiError({ - code: "bad_request", - message: - "Misconfigured authorization header. Did you forget to add 'Bearer '?", - }); - } - apiKey = authorizationHeader.replace("Bearer ", ""); - } - - if (!apiKey) { - throw new AgentsetApiError({ - code: "unauthorized", - message: "Unauthorized: Invalid API key.", - }); - } - - const orgApiKey = await tryCatch(getApiKeyInfo(apiKey)); - if (!orgApiKey.data) { - throw new AgentsetApiError({ - code: "unauthorized", - message: "Unauthorized: Invalid API key.", - }); - } - - const rateLimit = orgApiKey.data.organization.apiRatelimit; - const { success, limit, reset, remaining } = await ratelimit( - rateLimit, - "1 m", - ).limit(orgApiKey.data.organizationId); - - headers = { - "Retry-After": reset.toString(), - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": remaining.toString(), - "X-RateLimit-Reset": reset.toString(), - }; - - if (!success) { - throw new AgentsetApiError({ - code: "rate_limit_exceeded", - message: "Too many requests.", - }); - } - - const tenantId = getTenantFromRequest(req); - const organization = { - id: orgApiKey.data.organizationId, - ...orgApiKey.data.organization, - }; - - const response = await handler({ - req, - params: routeParams ?? {}, - searchParams, - organization, - apiScope: orgApiKey.data.scope, - headers, - tenantId, - }); - - // log request - if (logging) { - identifyOrganization(organization); - logServerEvent( - "api_request", - { - request: req, - routeName: logging.routeName, - response, - organization, - }, - { tenantId }, - ); - flushServerEvents(); - } - - return response; - } catch (error) { - console.error(error); - return handleAndReturnErrorResponse(error, headers); - } - }; -}; diff --git a/apps/web/src/lib/api/handler/namespace.ts b/apps/web/src/lib/api/handler/namespace.ts index eca8917e..02e9f068 100644 --- a/apps/web/src/lib/api/handler/namespace.ts +++ b/apps/web/src/lib/api/handler/namespace.ts @@ -1,26 +1,7 @@ import { unstable_cache } from "next/cache"; -import { - flushServerEvents, - identifyOrganization, - logServerEvent, -} from "@/lib/analytics-server"; -import type { Namespace } from "@agentset/db"; import { NamespaceStatus } from "@agentset/db"; import { db } from "@agentset/db/client"; -import { normalizeId } from "@agentset/utils"; - -import type { HandlerParams } from "./base"; -import { AgentsetApiError } from "../errors"; -import { withApiHandler } from "./base"; - -interface NamespaceHandler { - ( - params: HandlerParams & { - namespace: Namespace; - }, - ): Promise; -} export const getNamespace = async ({ namespaceId, @@ -46,58 +27,3 @@ export const getNamespace = async ({ }, )(); }; - -export const withNamespaceApiHandler = ( - handler: NamespaceHandler, - { logging }: { logging: false | { routeName: string } }, -) => { - return withApiHandler( - async (params) => { - const namespaceId = normalizeId(params.params.namespaceId ?? "", "ns_"); - if (!namespaceId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid namespace ID.", - }); - } - - const namespace = await getNamespace({ - namespaceId, - organizationId: params.organization.id, - }); - - if (!namespace) { - throw new AgentsetApiError({ - code: "unauthorized", - message: "Unauthorized: You don't have access to this namespace.", - }); - } - - const response = await handler({ - ...params, - namespace, - }); - - if (logging) { - identifyOrganization(params.organization); - - // log request - logServerEvent( - "api_request", - { - request: params.req, - routeName: logging.routeName, - response, - organization: params.organization, - }, - { namespaceId: namespace.id, tenantId: params.tenantId }, - ); - - flushServerEvents(); - } - - return response; - }, - { logging: false }, - ); -}; From 76509052a2990592b0db94d8ee14b45b579c1a97 Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Sat, 4 Jul 2026 15:03:14 +0200 Subject: [PATCH 08/10] Ignore internal-docs and .opencode (local-only) --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cdf15085..a96e9ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,7 @@ dist/ # turbo .turbo -*.local.* \ No newline at end of file +*.local.* +# local-only internal material — never commit +internal-docs/ +.opencode/ From 69b434b2394bbaaa16cea7e98bd6c0a3ad6072dc Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Sat, 4 Jul 2026 15:08:59 +0200 Subject: [PATCH 09/10] Ignore any *.local path and rename internal-docs to internal-docs.local The generic pattern makes the local-only convention self-enforcing: any file or directory named *.local is ignored without needing a dedicated gitignore entry. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index a96e9ff7..9d56de5c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,10 @@ dist/ .turbo *.local.* +*.local + # local-only internal material — never commit +# (keep such files in *.local paths so the patterns above catch them) internal-docs/ +internal-docs.local/ .opencode/ From 4c3e13e10d2fb456f67e6132892e35cdc9716b55 Mon Sep 17 00:00:00 2001 From: Abdellatif Date: Mon, 6 Jul 2026 13:07:31 +0200 Subject: [PATCH 10/10] Consolidate dashboard, REST, and MCP onto one oRPC router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback: the three parallel implementations (internal oRPC router, public oRPC router, hand-written MCP tools) are now a single router with a conditional auth middleware. - server/orpc/base.ts: one auth middleware — if an Authorization header is present, org API-key auth (wire-parity port: bearer parse, cached key lookup, per-org rate limit + headers, x-tenant-id, analytics); otherwise better-auth getSession() + x-organization-id header, membership-checked and hydrated to the same organization context. requireRoles() gates session members on mutations the dashboard previously restricted to admin/owner; API keys keep full org authority (public-API parity). - server/orpc/router/*: the single router, served by all three surfaces. Dashboard-only procedures (slug-addressed lookups, richer projections, billing, onboarding) stay session-gated and carry no route metadata, so they are excluded from REST routing, the generated spec, and MCP. - lib/mcp: the 37 hand-written tools are replaced by a generator that walks the router contract and registers one tool per published operation (39), with titles/descriptions/read-only/destructive hints from route metadata, executing via call() through the full middleware chain — REST/MCP parity by construction. - lib/orpc.ts + call sites: dashboard mutations moved onto the shared procedures with a per-call org header (batch-safe: oRPC preserves per-item headers; RequestHeadersPlugin restores them per batched call). - lib/api/handler/namespace.ts: fix pre-existing warm-cache 500 on GET /v1/namespace/{namespaceId} — unstable_cache revives DateTime columns as strings, failing the z.date() output schema. The generated OpenAPI document is byte-identical to the previous one (verified), typecheck/lint/tests are at baseline, and all three surfaces plus five dashboard flows were smoke-tested end-to-end. --- .../(internal-api)/rpc/[[...rest]]/route.ts | 44 ++- .../api/(public-api)/v1/[...rest]/route.ts | 13 +- .../[namespaceSlug]/documents/actions.tsx | 4 + .../documents/chunks-drawer.tsx | 17 +- .../documents/config-modal.tsx | 6 +- .../documents/document-actions.tsx | 8 +- .../documents/ingest-modal/index.tsx | 2 +- .../documents/ingest-modal/use-ingest.ts | 12 +- .../hosting/domain-card/index.tsx | 17 +- .../[namespaceSlug]/hosting/empty-state.tsx | 8 +- .../[namespaceSlug]/hosting/page.client.tsx | 24 +- .../hosting/use-hosting-form.ts | 47 +-- .../playground/search/page.client.tsx | 2 +- .../danger/delete-namespace-button.tsx | 3 +- .../[slug]/settings/api-keys/actions.tsx | 6 +- .../settings/api-keys/create-api-key.tsx | 23 +- .../[slug]/webhooks/[webhookId]/edit/page.tsx | 7 +- .../src/components/create-namespace/index.tsx | 12 +- .../webhooks/add-edit-webhook-form.tsx | 34 +- .../components/webhooks/webhook-header.tsx | 38 +- apps/web/src/hooks/use-upload.ts | 9 +- apps/web/src/hooks/use-webhooks.ts | 2 +- apps/web/src/lib/api/handler/namespace.ts | 13 +- apps/web/src/lib/mcp/index.ts | 209 +++++++++-- apps/web/src/lib/mcp/run-tool.ts | 89 ----- apps/web/src/lib/mcp/schemas.ts | 7 - apps/web/src/lib/mcp/tools/api-keys.ts | 90 ----- apps/web/src/lib/mcp/tools/chat.ts | 63 ---- apps/web/src/lib/mcp/tools/documents.ts | 188 ---------- apps/web/src/lib/mcp/tools/hosting.ts | 212 ----------- apps/web/src/lib/mcp/tools/ingestion.ts | 256 ------------- apps/web/src/lib/mcp/tools/namespaces.ts | 125 ------- apps/web/src/lib/mcp/tools/organization.ts | 92 ----- apps/web/src/lib/mcp/tools/search.ts | 48 --- apps/web/src/lib/mcp/tools/webhooks.ts | 179 --------- apps/web/src/lib/orpc.ts | 26 +- apps/web/src/server/orpc/base.ts | 344 ++++++++++++----- apps/web/src/server/orpc/internal/api-keys.ts | 112 ------ .../web/src/server/orpc/internal/documents.ts | 192 ---------- apps/web/src/server/orpc/internal/domains.ts | 68 ---- apps/web/src/server/orpc/internal/hosting.ts | 73 ---- .../src/server/orpc/internal/ingest-jobs.ts | 154 -------- .../src/server/orpc/internal/namespaces.ts | 277 -------------- .../src/server/orpc/internal/organizations.ts | 130 ------- apps/web/src/server/orpc/internal/router.ts | 31 -- apps/web/src/server/orpc/internal/search.ts | 60 --- apps/web/src/server/orpc/internal/uploads.ts | 106 ------ apps/web/src/server/orpc/internal/webhooks.ts | 351 ------------------ apps/web/src/server/orpc/public/router.ts | 32 -- .../orpc/{public => router}/api-keys.ts | 57 ++- .../orpc/{internal => router}/billing.ts | 21 -- .../server/orpc/{public => router}/chat.ts | 4 +- .../orpc/{public => router}/code-samples.ts | 0 .../orpc/{public => router}/documents.ts | 80 +++- .../orpc/{internal => router}/helpers.ts | 0 .../server/orpc/{public => router}/hosting.ts | 72 +++- apps/web/src/server/orpc/router/index.ts | 43 +++ .../orpc/{public => router}/ingest-jobs.ts | 70 +++- .../orpc/{public => router}/namespaces.ts | 207 ++++++++++- .../orpc/{public => router}/organization.ts | 138 ++++++- .../server/orpc/{public => router}/search.ts | 66 +++- .../server/orpc/{public => router}/uploads.ts | 6 +- .../server/orpc/{public => router}/warm-up.ts | 4 +- .../orpc/{public => router}/webhooks.ts | 153 +++++++- apps/web/src/server/orpc/spec.ts | 13 +- 65 files changed, 1536 insertions(+), 3263 deletions(-) delete mode 100644 apps/web/src/lib/mcp/run-tool.ts delete mode 100644 apps/web/src/lib/mcp/schemas.ts delete mode 100644 apps/web/src/lib/mcp/tools/api-keys.ts delete mode 100644 apps/web/src/lib/mcp/tools/chat.ts delete mode 100644 apps/web/src/lib/mcp/tools/documents.ts delete mode 100644 apps/web/src/lib/mcp/tools/hosting.ts delete mode 100644 apps/web/src/lib/mcp/tools/ingestion.ts delete mode 100644 apps/web/src/lib/mcp/tools/namespaces.ts delete mode 100644 apps/web/src/lib/mcp/tools/organization.ts delete mode 100644 apps/web/src/lib/mcp/tools/search.ts delete mode 100644 apps/web/src/lib/mcp/tools/webhooks.ts delete mode 100644 apps/web/src/server/orpc/internal/api-keys.ts delete mode 100644 apps/web/src/server/orpc/internal/documents.ts delete mode 100644 apps/web/src/server/orpc/internal/domains.ts delete mode 100644 apps/web/src/server/orpc/internal/hosting.ts delete mode 100644 apps/web/src/server/orpc/internal/ingest-jobs.ts delete mode 100644 apps/web/src/server/orpc/internal/namespaces.ts delete mode 100644 apps/web/src/server/orpc/internal/organizations.ts delete mode 100644 apps/web/src/server/orpc/internal/router.ts delete mode 100644 apps/web/src/server/orpc/internal/search.ts delete mode 100644 apps/web/src/server/orpc/internal/uploads.ts delete mode 100644 apps/web/src/server/orpc/internal/webhooks.ts delete mode 100644 apps/web/src/server/orpc/public/router.ts rename apps/web/src/server/orpc/{public => router}/api-keys.ts (71%) rename apps/web/src/server/orpc/{internal => router}/billing.ts (94%) rename apps/web/src/server/orpc/{public => router}/chat.ts (97%) rename apps/web/src/server/orpc/{public => router}/code-samples.ts (100%) rename apps/web/src/server/orpc/{public => router}/documents.ts (83%) rename apps/web/src/server/orpc/{internal => router}/helpers.ts (100%) rename apps/web/src/server/orpc/{public => router}/hosting.ts (82%) create mode 100644 apps/web/src/server/orpc/router/index.ts rename apps/web/src/server/orpc/{public => router}/ingest-jobs.ts (85%) rename apps/web/src/server/orpc/{public => router}/namespaces.ts (54%) rename apps/web/src/server/orpc/{public => router}/organization.ts (52%) rename apps/web/src/server/orpc/{public => router}/search.ts (60%) rename apps/web/src/server/orpc/{public => router}/uploads.ts (97%) rename apps/web/src/server/orpc/{public => router}/warm-up.ts (96%) rename apps/web/src/server/orpc/{public => router}/webhooks.ts (68%) diff --git a/apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts b/apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts index 2f50b219..6186ea77 100644 --- a/apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts +++ b/apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts @@ -1,12 +1,41 @@ -import type { InternalContext } from "@/server/orpc/base"; +import type { ApiContext } from "@/server/orpc/base"; import { auth } from "@/lib/auth"; -import { internalRouter } from "@/server/orpc/internal/router"; +import { toDashboardORPCError } from "@/server/orpc/base"; +import { appRouter } from "@/server/orpc/router"; import { onError } from "@orpc/server"; import { RPCHandler } from "@orpc/server/fetch"; -import { BatchHandlerPlugin } from "@orpc/server/plugins"; +import { + BatchHandlerPlugin, + RequestHeadersPlugin, +} from "@orpc/server/plugins"; -const handler = new RPCHandler(internalRouter, { - plugins: [new BatchHandlerPlugin()], +const handler = new RPCHandler(appRouter, { + plugins: [ + new BatchHandlerPlugin(), + // injects per-item headers as context.reqHeaders AFTER batch splitting, + // so each batched call authenticates with its own x-organization-id + new RequestHeadersPlugin(), + ], + clientInterceptors: [ + // port of the old timingMiddleware: timing log + AgentsetApiError → typed + // transport error (dashboard mutations surface 4xx instead of 500) + async (options) => { + const start = Date.now(); + + try { + const result = await options.next(); + + const end = Date.now(); + console.log( + `[RPC] ${options.path.join(".")} took ${end - start}ms to execute`, + ); + + return result; + } catch (error) { + throw toDashboardORPCError(error); + } + }, + ], interceptors: [ onError((error) => { console.error(error); @@ -19,8 +48,9 @@ async function handleRequest(request: Request) { // shared across batched procedure calls const session = await auth.api.getSession({ headers: request.headers }); - const context: InternalContext = { - headers: request.headers, + const context: ApiContext = { + resHeaders: {}, + analytics: {}, session, }; diff --git a/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts b/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts index 8b8d11c5..4821571b 100644 --- a/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts +++ b/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts @@ -1,4 +1,4 @@ -import type { PublicApiContext } from "@/server/orpc/base"; +import type { ApiContext } from "@/server/orpc/base"; import type { NextRequest } from "next/server"; import { flushServerEvents, @@ -9,7 +9,7 @@ import { legacyErrorORPCError, legacyErrorResponseBodyEncoder, } from "@/server/orpc/base"; -import { publicRouter } from "@/server/orpc/public/router"; +import { appRouter } from "@/server/orpc/router"; import { SmartCoercionPlugin } from "@orpc/json-schema"; import { OpenAPIHandler } from "@orpc/openapi/fetch"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; @@ -17,7 +17,10 @@ import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; export const preferredRegion = "iad1"; // make this closer to the DB export const maxDuration = 120; -const handler = new OpenAPIHandler(publicRouter, { +const handler = new OpenAPIHandler(appRouter, { + // dashboard-only procedures carry no route metadata — keep them off the + // public REST surface (the hidden PUT aliases have a path, so they stay) + filter: ({ contract }) => !!contract["~orpc"].route.path, plugins: [ new SmartCoercionPlugin({ schemaConverters: [new ZodToJsonSchemaConverter()], @@ -50,8 +53,8 @@ const notFoundResponse = () => ); async function handleRequest(request: NextRequest) { - const context: PublicApiContext = { - headers: request.headers, + const context: ApiContext = { + reqHeaders: request.headers, resHeaders: {}, analytics: {}, }; diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/actions.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/actions.tsx index 588ec0a6..9962a901 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/actions.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/actions.tsx @@ -2,6 +2,7 @@ import type { Row } from "@tanstack/react-table"; import { useState } from "react"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation"; import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -27,10 +28,12 @@ import type { JobCol } from "./columns"; export function JobActions({ row }: { row: Row }) { const queryClient = useQueryClient(); const namespace = useNamespace(); + const organization = useOrganization(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { mutate: deleteJob, isPending: isDeletePending } = useMutation( orpc.ingestJob.delete.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { toast.success("Job queued for deletion"); setDeleteDialogOpen(false); @@ -49,6 +52,7 @@ export function JobActions({ row }: { row: Row }) { const { mutate: reIngestJob, isPending: isReIngestPending } = useMutation( orpc.ingestJob.reIngest.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { toast.success("Job re-ingestion started"); void queryClient.invalidateQueries({ diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/chunks-drawer.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/chunks-drawer.tsx index 33f01dbe..56cf4d4b 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/chunks-drawer.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/chunks-drawer.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { client } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { ChevronDownIcon, CopyIcon, SearchIcon } from "lucide-react"; @@ -37,23 +38,27 @@ export function ChunksDrawer({ onOpenChange, }: ChunksDrawerProps) { const namespace = useNamespace(); + const organization = useOrganization(); const [search, setSearch] = useState(""); const { data, isLoading, error, refetch } = useQuery({ queryKey: ["document-chunks", namespace.id, documentId], queryFn: async () => { // First fetch the presigned URL - const urlResponse = await client.document.getChunksDownloadUrl({ - documentId, - namespaceId: namespace.id, - }); + const urlResponse = await client.document.getChunksDownloadUrl( + { + documentId, + namespaceId: namespace.id, + }, + { context: { orgId: organization.id } }, + ); - if (!urlResponse?.url) { + if (!urlResponse.data.url) { throw new Error("Could not get chunks URL"); } // Then fetch the chunks JSON - const response = await fetch(urlResponse.url); + const response = await fetch(urlResponse.data.url); if (!response.ok) { throw new Error("Failed to fetch chunks"); } diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/config-modal.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/config-modal.tsx index 8d5a597c..14597533 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/config-modal.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/config-modal.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; @@ -17,13 +18,16 @@ import { Skeleton } from "@agentset/ui/skeleton"; export function ConfigModal({ jobId }: { jobId: string }) { const [open, setOpen] = useState(false); const namespace = useNamespace(); + const organization = useOrganization(); const { data: config, isLoading } = useQuery({ - ...orpc.ingestJob.getConfig.queryOptions({ + ...orpc.ingestJob.get.queryOptions({ input: { jobId, namespaceId: namespace.id, }, + context: { orgId: organization.id }, enabled: open, + select: (res) => res.data.config, }), }); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx index cea259ae..23ade189 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/document-actions.tsx @@ -2,6 +2,7 @@ import type { Row } from "@tanstack/react-table"; import { useState } from "react"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation"; import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -28,11 +29,13 @@ import type { DocumentCol } from "./documents-columns"; export default function DocumentActions({ row }: { row: Row }) { const namespace = useNamespace(); + const organization = useOrganization(); const queryClient = useQueryClient(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { isPending, mutate: deleteDocument } = useMutation( orpc.document.delete.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { logEvent("document_deleted", { documentId: row.original.id, @@ -56,8 +59,9 @@ export default function DocumentActions({ row }: { row: Row }) { const { isPending: isDownloading, mutate: getDownloadUrl } = useMutation( orpc.document.getFileDownloadUrl.mutationOptions({ - onSuccess: ({ url }) => { - window.open(url, "_blank"); + context: { orgId: organization.id }, + onSuccess: (res) => { + window.open(res.data.url, "_blank"); }, onError: (error) => { toast.error(error.message); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/index.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/index.tsx index 46e828b7..e95aa9c1 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/index.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/index.tsx @@ -60,7 +60,7 @@ export function IngestModal() { const isPending = queryClient.isMutating({ - mutationKey: orpc.ingestJob.ingest.mutationKey(), + mutationKey: orpc.ingestJob.create.mutationKey(), }) > 0; const isOverLimit = diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/use-ingest.ts b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/use-ingest.ts index c2c4ce43..deb32674 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/use-ingest.ts +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/documents/ingest-modal/use-ingest.ts @@ -1,4 +1,5 @@ import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; import { orpc } from "@/lib/orpc"; import { useMutation } from "@tanstack/react-query"; @@ -42,17 +43,20 @@ export function useIngest({ extraAnalytics, }: UseIngestOptions) { const namespace = useNamespace(); + const organization = useOrganization(); const mutation = useMutation( - orpc.ingestJob.ingest.mutationOptions({ - onSuccess: (doc) => { + orpc.ingestJob.create.mutationOptions({ + context: { orgId: organization.id }, + onSuccess: (res) => { + const job = res.data; logEvent( "document_ingested", buildAnalyticsData( type, namespace.id, - doc.config, - extraAnalytics?.(doc), + job.config, + extraAnalytics?.(job), ), ); onSuccess(); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx index 26a51190..e7fdc955 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/domain-card/index.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; import { SHORT_DOMAIN } from "@/lib/constants"; import { orpc } from "@/lib/orpc"; @@ -28,7 +29,7 @@ const A_VALUE = "76.76.21.21"; export function useDomainStatus() { const namespace = useNamespace(); const { data, isFetching, refetch } = useQuery( - orpc.domain.checkStatus.queryOptions({ + orpc.hosting.domainStatusDetailed.queryOptions({ input: { namespaceId: namespace.id }, refetchInterval: 20000, }), @@ -228,9 +229,11 @@ const DomainControls = ({ }) => { const { refetch, loading } = useDomainStatus(); const namespace = useNamespace(); + const organization = useOrganization(); const queryClient = useQueryClient(); const { mutate: removeDomain, isPending: isRemovingDomain } = useMutation( - orpc.domain.remove.mutationOptions({ + orpc.hosting.removeDomain.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { logEvent("domain_removed", { domain, @@ -282,15 +285,17 @@ export function CustomDomainConfigurator(props: { defaultDomain?: string }) { ); const namespace = useNamespace(); + const organization = useOrganization(); const { mutate: addDomain, isPending } = useMutation( - orpc.domain.add.mutationOptions({ - onSuccess: (data) => { + orpc.hosting.addDomain.mutationOptions({ + context: { orgId: organization.id }, + onSuccess: (res) => { logEvent("domain_added", { - domain: data.slug, + domain: res.data.slug, namespaceId: namespace.id, }); - setDomain(data.slug); + setDomain(res.data.slug); }, onError: (error) => { toast.error(error.message || "Failed to add domain"); diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx index 9446055f..34bacbbc 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/empty-state.tsx @@ -1,4 +1,5 @@ import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { logEvent } from "@/lib/analytics"; import { orpc } from "@/lib/orpc"; @@ -18,11 +19,13 @@ import { toast } from "sonner"; export function EmptyState() { const namespace = useNamespace(); + const organization = useOrganization(); const queryClient = useQueryClient(); const { mutate: enableHosting, isPending } = useMutation( orpc.hosting.enable.mutationOptions({ - onSuccess: (hosting) => { + context: { orgId: organization.id }, + onSuccess: (res) => { logEvent("hosting_enabled", { namespaceId: namespace.id, }); @@ -32,7 +35,8 @@ export function EmptyState() { input: { namespaceId: namespace.id }, }), () => { - return { ...hosting, domain: null }; + // the hosting.get cache holds the enveloped shape + return { success: true as const, data: res.data }; }, ); }, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx index 478ff93b..1edc29de 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/page.client.tsx @@ -1,7 +1,9 @@ "use client"; import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { orpc } from "@/lib/orpc"; +import { ORPCError } from "@orpc/client"; import { useQuery } from "@tanstack/react-query"; import { Skeleton } from "@agentset/ui/skeleton"; @@ -9,11 +11,18 @@ import { Skeleton } from "@agentset/ui/skeleton"; import { HostingLayout } from "./components/hosting-layout"; import { EmptyState } from "./empty-state"; +const isNotFoundError = (error: unknown) => + error instanceof ORPCError && error.code === "NOT_FOUND"; + export default function HostingPageClient() { const namespace = useNamespace(); - const { data, isLoading } = useQuery( + const organization = useOrganization(); + const { data, isLoading, error } = useQuery( orpc.hosting.get.queryOptions({ input: { namespaceId: namespace.id }, + context: { orgId: organization.id }, + select: (result) => result.data, + retry: (count, error) => (isNotFoundError(error) ? false : count < 3), }), ); @@ -21,10 +30,21 @@ export default function HostingPageClient() { return ; } - if (!data) { + // hosting not enabled yet — the shared procedure 404s instead of returning null + if (isNotFoundError(error)) { return ; } + if (error || !data) { + return ( +
+

+ {error?.message ?? "Failed to load hosting settings."} +

+
+ ); + } + return ; } diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/use-hosting-form.ts b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/use-hosting-form.ts index d137cea1..c07f6d64 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/use-hosting-form.ts +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/hosting/use-hosting-form.ts @@ -1,5 +1,6 @@ import type { RouterOutputs } from "@/lib/orpc"; import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { AGENTIC_SYSTEM_PROMPT, isKnownDefaultPrompt, @@ -12,12 +13,7 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod/v4"; -import { - DEFAULT_LLM, - DEFAULT_RERANKER, - llmSchema, - rerankerSchema, -} from "@agentset/validation"; +import { llmSchema, rerankerSchema } from "@agentset/validation"; export const hostingFormSchema = z.object({ title: z.string().min(1, "Title is required"), @@ -43,24 +39,26 @@ export const hostingFormSchema = z.object({ export type HostingFormValues = z.infer; -export type HostingData = NonNullable; +export type HostingData = RouterOutputs["hosting"]["get"]["data"]; export function useHostingForm(data: HostingData) { const namespace = useNamespace(); + const organization = useOrganization(); const queryClient = useQueryClient(); const { mutateAsync: updateHosting, isPending: isUpdating } = useMutation( orpc.hosting.update.mutationOptions({ + context: { orgId: organization.id }, onSuccess: (result) => { logEvent("hosting_updated", { namespaceId: namespace.id, - slug: result.slug, - protected: result.protected, - searchEnabled: result.searchEnabled, - hasCustomPrompt: !!result.systemPrompt, - hasWelcomeMessage: !!result.welcomeMessage, - exampleQuestionsCount: result.exampleQuestions?.length || 0, - exampleSearchQueriesCount: result.exampleSearchQueries?.length || 0, + slug: result.data.slug, + protected: result.data.protected, + searchEnabled: result.data.searchEnabled, + hasCustomPrompt: !!result.data.systemPrompt, + hasWelcomeMessage: !!result.data.welcomeMessage, + exampleQuestionsCount: result.data.exampleQuestions.length, + exampleSearchQueriesCount: result.data.exampleSearchQueries.length, }); toast.success("Hosting settings saved"); queryClient.setQueryData( @@ -68,10 +66,14 @@ export function useHostingForm(data: HostingData) { input: { namespaceId: namespace.id }, }), (old) => { + // the hosting.get cache holds the enveloped shape return { - ...(old ?? {}), - ...result, - domain: old?.domain || null, + success: true as const, + data: { + ...old?.data, + ...result.data, + domain: old?.data.domain ?? null, + }, }; }, ); @@ -114,9 +116,10 @@ export function useHostingForm(data: HostingData) { welcomeMessage: data.welcomeMessage || "", citationMetadataPath: data.citationMetadataPath || "", searchEnabled: data.searchEnabled, - rerankModel: data.rerankConfig?.model ?? DEFAULT_RERANKER, - rerankLimit: data.rerankConfig?.limit ?? 15, - llmModel: data.llmConfig?.model ?? DEFAULT_LLM, + // the shared hosting.get output applies the model defaults server-side + rerankModel: data.rerankConfig.model, + rerankLimit: data.rerankConfig.limit, + llmModel: data.llmConfig.model, topK: data.topK, }, }); @@ -126,10 +129,10 @@ export function useHostingForm(data: HostingData) { namespaceId: namespace.id, title: newData.title, slug: newData.slug, - logo: data?.logo === newData.logo ? undefined : newData.logo, + logo: data.logo === newData.logo ? undefined : newData.logo, ogTitle: newData.ogTitle, ogDescription: newData.ogDescription, - ogImage: data?.ogImage === newData.ogImage ? undefined : newData.ogImage, + ogImage: data.ogImage === newData.ogImage ? undefined : newData.ogImage, protected: newData.protected, allowedEmails: newData.allowedEmails, allowedEmailDomains: newData.allowedEmailDomains, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx index e9b6bba2..ec94682e 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/playground/search/page.client.tsx @@ -40,7 +40,7 @@ export default function ChunkExplorerPageClient() { const [rerankLimit, setRerankLimit] = useState(20); const { data, isLoading, isFetching, error, isEnabled } = useQuery( - orpc.search.search.queryOptions({ + orpc.search.playground.queryOptions({ input: { namespaceId: namespace.id, query: searchQuery, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx index 3eee488f..33a02b59 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/[namespaceSlug]/settings/danger/delete-namespace-button.tsx @@ -18,7 +18,8 @@ export function DeleteNamespaceButton() { const queryClient = useQueryClient(); const { mutate: deleteNamespace, isPending } = useMutation( - orpc.namespace.deleteNamespace.mutationOptions({ + orpc.namespace.delete.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { logEvent("namespace_deleted", { id: namespace.id, diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/actions.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/actions.tsx index 01efdb28..7b8fee5b 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/actions.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/actions.tsx @@ -21,7 +21,9 @@ export function ApiKeyActions({ row }: { row: Row }) { const id = row.original.id; const { mutateAsync: deleteApiKey, isPending } = useMutation( - orpc.apiKey.deleteApiKey.mutationOptions({ + orpc.apiKey.delete.mutationOptions({ + // rows come from the session getApiKeys cache, so organizationId is raw + context: { orgId }, onSuccess: () => { const queryKey = orpc.apiKey.getApiKeys.queryKey({ input: { orgId }, @@ -43,7 +45,7 @@ export function ApiKeyActions({ row }: { row: Row }) { ); const handleDelete = async () => { - await deleteApiKey({ orgId, id }); + await deleteApiKey({ keyId: id }); }; return ( diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx index b339f0c6..5177ae86 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/settings/api-keys/create-api-key.tsx @@ -33,20 +33,12 @@ export default function CreateApiKey({ orgId }: { orgId: string }) { const queryClient = useQueryClient(); const { isPending, mutateAsync, data, reset } = useMutation( - orpc.apiKey.createApiKey.mutationOptions({ - onSuccess: (newKey) => { + orpc.apiKey.create.mutationOptions({ + context: { orgId }, + onSuccess: (res) => { logEvent("api_key_created", { orgId, - scope: newKey.scope, - }); - - const queryKey = orpc.apiKey.getApiKeys.queryKey({ - input: { orgId }, - }); - - queryClient.setQueryData(queryKey, (old) => { - if (!old) return []; - return [...old, newKey]; + scope: res.data.scope, }); toast.success("API key created"); @@ -63,7 +55,6 @@ export default function CreateApiKey({ orgId }: { orgId: string }) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await mutateAsync({ - organizationId: orgId, label, scope, }); @@ -99,10 +90,10 @@ export default function CreateApiKey({ orgId }: { orgId: string }) {
-              {data.key}
+              {data.data.key}
               
             
@@ -125,6 +116,8 @@ export default function CreateApiKey({ orgId }: { orgId: string }) { className="col-span-3" value={label} onChange={(e) => setLabel(e.target.value)} + // the shared create schema rejects empty labels + required /> diff --git a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/webhooks/[webhookId]/edit/page.tsx b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/webhooks/[webhookId]/edit/page.tsx index 6611ac41..13ef6141 100644 --- a/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/webhooks/[webhookId]/edit/page.tsx +++ b/apps/web/src/app/app.agentset.ai/(dashboard)/[slug]/webhooks/[webhookId]/edit/page.tsx @@ -14,10 +14,9 @@ export default function EditWebhookPage() { const { data: webhook, isLoading } = useQuery( orpc.webhook.get.queryOptions({ - input: { - organizationId: organization.id, - webhookId: params.webhookId, - }, + input: { webhookId: params.webhookId }, + context: { orgId: organization.id }, + select: (r) => r.data, }), ); diff --git a/apps/web/src/components/create-namespace/index.tsx b/apps/web/src/components/create-namespace/index.tsx index 62bb57e9..d7b5437c 100644 --- a/apps/web/src/components/create-namespace/index.tsx +++ b/apps/web/src/components/create-namespace/index.tsx @@ -16,7 +16,7 @@ import { DialogHeader, DialogTitle, } from "@agentset/ui/dialog"; -import { toSlug } from "@agentset/utils"; +import { normalizeId, toSlug } from "@agentset/utils"; import CreateNamespaceDetailsStep from "./details-step"; import CreateNamespaceEmbeddingStep from "./embedding-step"; @@ -87,12 +87,14 @@ export default function CreateNamespaceDialog({ const [slug, setSlug] = useState(defaultSlug); const { isPending, mutateAsync: createNamespace } = useMutation( - orpc.namespace.createNamespace.mutationOptions({ - onSuccess: (data) => { + orpc.namespace.create.mutationOptions({ + context: { orgId: organization.id }, + onSuccess: (res) => { + const data = res.data; logEvent("namespace_created", { name: data.name, slug: data.slug, - organizationId: data.organizationId, + organizationId: normalizeId(data.organizationId, "org_"), embeddingModel: data.embeddingConfig ? { provider: data.embeddingConfig.provider, @@ -117,7 +119,6 @@ export default function CreateNamespaceDialog({ const queryKey = orpc.namespace.getOrgNamespaces.queryKey({ input: { slug: organization.slug }, }); - queryClient.setQueryData(queryKey, (old) => [data, ...(old ?? [])]); void queryClient.invalidateQueries({ queryKey }); router.push(`/${organization.slug}/${data.slug}/quick-start`); } @@ -132,7 +133,6 @@ export default function CreateNamespaceDialog({ if (!organization) return; await createNamespace({ - orgId: organization.id, name: name, slug: slug, embeddingConfig: embeddingModel, diff --git a/apps/web/src/components/webhooks/add-edit-webhook-form.tsx b/apps/web/src/components/webhooks/add-edit-webhook-form.tsx index effd4099..3d670841 100644 --- a/apps/web/src/components/webhooks/add-edit-webhook-form.tsx +++ b/apps/web/src/components/webhooks/add-edit-webhook-form.tsx @@ -8,6 +8,7 @@ import { InfoIcon, RefreshCwIcon } from "lucide-react"; import { toast } from "sonner"; import { z } from "zod/v4"; +import { normalizeId } from "@agentset/utils"; import { Button } from "@agentset/ui/button"; import { Checkbox } from "@agentset/ui/checkbox"; import { CopyButton } from "@agentset/ui/copy-button"; @@ -68,7 +69,10 @@ export default function AddEditWebhookForm({ name: webhook?.name || "", url: webhook?.url || "", triggers: (webhook?.triggers as WebhookFormValues["triggers"]) || [], - namespaceIds: webhook?.namespaceIds || [], + // shared webhook.get returns ns_-prefixed ids; keep form state raw so it + // matches getNamespaces ids (checkbox comparison) and submits raw ids + namespaceIds: + webhook?.namespaceIds?.map((id) => normalizeId(id, "ns_")) || [], }, }); @@ -78,10 +82,13 @@ export default function AddEditWebhookForm({ const createMutation = useMutation( orpc.webhook.create.mutationOptions({ + context: { orgId: organizationId }, onSuccess: () => { toast.success("Webhook created"); queryClient.invalidateQueries({ - queryKey: orpc.webhook.list.queryKey({ input: { organizationId } }), + queryKey: orpc.webhook.listByOrg.queryKey({ + input: { organizationId }, + }), }); router.push(`/${organizationSlug}/webhooks`); }, @@ -93,17 +100,17 @@ export default function AddEditWebhookForm({ const updateMutation = useMutation( orpc.webhook.update.mutationOptions({ + context: { orgId: organizationId }, onSuccess: () => { toast.success("Webhook updated"); queryClient.invalidateQueries({ - queryKey: orpc.webhook.list.queryKey({ input: { organizationId } }), + queryKey: orpc.webhook.listByOrg.queryKey({ + input: { organizationId }, + }), }); queryClient.invalidateQueries({ queryKey: orpc.webhook.get.queryKey({ - input: { - organizationId, - webhookId: webhook!.id, - }, + input: { webhookId: webhook!.id }, }), }); router.push(`/${organizationSlug}/webhooks/${webhook!.id}`); @@ -116,14 +123,12 @@ export default function AddEditWebhookForm({ const regenerateSecretMutation = useMutation( orpc.webhook.regenerateSecret.mutationOptions({ + context: { orgId: organizationId }, onSuccess: () => { toast.success("Secret regenerated"); queryClient.invalidateQueries({ queryKey: orpc.webhook.get.queryKey({ - input: { - organizationId, - webhookId: webhook!.id, - }, + input: { webhookId: webhook!.id }, }), }); }, @@ -142,14 +147,10 @@ export default function AddEditWebhookForm({ if (isEditing) { updateMutation.mutate({ ...payload, - organizationId, webhookId: webhook.id, }); } else { - createMutation.mutate({ - ...payload, - organizationId, - }); + createMutation.mutate(payload); } }; @@ -242,7 +243,6 @@ export default function AddEditWebhookForm({ className="size-8" onClick={() => regenerateSecretMutation.mutate({ - organizationId, webhookId: webhook.id, }) } diff --git a/apps/web/src/components/webhooks/webhook-header.tsx b/apps/web/src/components/webhooks/webhook-header.tsx index 6284f1c7..580398b0 100644 --- a/apps/web/src/components/webhooks/webhook-header.tsx +++ b/apps/web/src/components/webhooks/webhook-header.tsx @@ -48,10 +48,9 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { const { data: webhook, isLoading } = useQuery( orpc.webhook.get.queryOptions({ - input: { - organizationId: organization.id, - webhookId, - }, + input: { webhookId }, + context: { orgId: organization.id }, + select: (r) => r.data, }), ); @@ -60,10 +59,11 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { const deleteMutation = useMutation( orpc.webhook.delete.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { toast.success("Webhook deleted"); queryClient.invalidateQueries({ - queryKey: orpc.webhook.list.queryKey({ + queryKey: orpc.webhook.listByOrg.queryKey({ input: { organizationId: organization.id }, }), }); @@ -76,21 +76,19 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { ); const toggleMutation = useMutation( - orpc.webhook.toggle.mutationOptions({ + orpc.webhook.update.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { toast.success( webhook?.disabledAt ? "Webhook enabled" : "Webhook disabled", ); queryClient.invalidateQueries({ queryKey: orpc.webhook.get.queryKey({ - input: { - organizationId: organization.id, - webhookId, - }, + input: { webhookId }, }), }); queryClient.invalidateQueries({ - queryKey: orpc.webhook.list.queryKey({ + queryKey: orpc.webhook.listByOrg.queryKey({ input: { organizationId: organization.id }, }), }); @@ -128,12 +126,7 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { title="Delete webhook" description="This will permanently delete this webhook and stop all event deliveries." itemName={webhook?.name ?? ""} - onConfirm={() => - deleteMutation.mutate({ - organizationId: organization.id, - webhookId, - }) - } + onConfirm={() => deleteMutation.mutate({ webhookId })} isLoading={deleteMutation.isPending} /> @@ -196,13 +189,14 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { + onClick={() => { + if (!webhook) return; toggleMutation.mutate({ - organizationId: organization.id, webhookId, - }) - } - disabled={toggleMutation.isPending} + enabled: !!webhook.disabledAt, + }); + }} + disabled={toggleMutation.isPending || !webhook} > {webhook?.disabledAt ? ( <> diff --git a/apps/web/src/hooks/use-upload.ts b/apps/web/src/hooks/use-upload.ts index fc121302..44e7be3c 100644 --- a/apps/web/src/hooks/use-upload.ts +++ b/apps/web/src/hooks/use-upload.ts @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useOrganization } from "@/hooks/use-organization"; import { orpc } from "@/lib/orpc"; import { ORPCError } from "@orpc/client"; import { useMutation } from "@tanstack/react-query"; @@ -43,8 +44,11 @@ const uploadWithProgress = ( }; export function useUploadFiles({ namespaceId }: { namespaceId: string }) { + const organization = useOrganization(); const { mutateAsync: getPresignedUrls } = useMutation( - orpc.upload.getPresignedUrls.mutationOptions(), + orpc.upload.createBatch.mutationOptions({ + context: { orgId: organization.id }, + }), ); const [uploadedFiles, setUploadedFiles] = useState< @@ -68,8 +72,9 @@ export function useUploadFiles({ namespaceId }: { namespaceId: string }) { })), }); + // results are returned in the same order as the input files array await Promise.all( - presignResponses.map(async (presignResponse, i) => { + presignResponses.data.map(async (presignResponse, i) => { const file = files[i]!; await uploadWithProgress(presignResponse.url, file, { diff --git a/apps/web/src/hooks/use-webhooks.ts b/apps/web/src/hooks/use-webhooks.ts index ba204214..984165fc 100644 --- a/apps/web/src/hooks/use-webhooks.ts +++ b/apps/web/src/hooks/use-webhooks.ts @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; export function useWebhooks(organizationId: string) { const { data, isLoading, error } = useQuery( - orpc.webhook.list.queryOptions({ + orpc.webhook.listByOrg.queryOptions({ input: { organizationId }, enabled: !!organizationId, staleTime: 60000, // 1 minute diff --git a/apps/web/src/lib/api/handler/namespace.ts b/apps/web/src/lib/api/handler/namespace.ts index 02e9f068..c91201f0 100644 --- a/apps/web/src/lib/api/handler/namespace.ts +++ b/apps/web/src/lib/api/handler/namespace.ts @@ -10,7 +10,7 @@ export const getNamespace = async ({ namespaceId: string; organizationId: string; }) => { - return unstable_cache( + const namespace = await unstable_cache( async () => { return await db.namespace.findUnique({ where: { @@ -26,4 +26,15 @@ export const getNamespace = async ({ tags: [`org:${organizationId}`, `ns:${namespaceId}`], }, )(); + + if (!namespace) return namespace; + + // unstable_cache JSON-serializes its value, so cache hits revive DateTime + // columns as strings — which fails the z.date() output schemas downstream + // (GET /v1/namespace/{namespaceId} 500'd on a warm cache) + return { + ...namespace, + createdAt: new Date(namespace.createdAt), + updatedAt: new Date(namespace.updatedAt), + }; }; diff --git a/apps/web/src/lib/mcp/index.ts b/apps/web/src/lib/mcp/index.ts index 76971db9..2b80a703 100644 --- a/apps/web/src/lib/mcp/index.ts +++ b/apps/web/src/lib/mcp/index.ts @@ -1,43 +1,204 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpAuthContext } from "@/lib/mcp/auth"; +import type { ApiContext } from "@/server/orpc/base"; +import type { AnyProcedure, AnyRouter } from "@orpc/server"; +import { toLegacyErrorParts } from "@/server/orpc/base"; +import { appRouter } from "@/server/orpc/router"; +import { call, getRouter, traverseContractProcedures } from "@orpc/server"; +import { z } from "zod/v4"; -import { registerApiKeyTools } from "./tools/api-keys"; -import { registerChatTools } from "./tools/chat"; -import { registerDocumentTools } from "./tools/documents"; -import { registerHostingTools } from "./tools/hosting"; -import { registerIngestionTools } from "./tools/ingestion"; -import { registerNamespaceTools } from "./tools/namespaces"; -import { registerOrganizationTools } from "./tools/organization"; -import { registerSearchTools } from "./tools/search"; -import { registerWebhookTools } from "./tools/webhooks"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { + CallToolResult, + ServerNotification, + ServerRequest, +} from "@modelcontextprotocol/sdk/types.js"; export const MCP_SERVER_INFO = { name: "agentset", version: "1.0.0", }; -export const MCP_SERVER_INSTRUCTIONS = `Agentset is a RAG-as-a-service platform. This server operates one organization, determined by the API key. +export const MCP_SERVER_INSTRUCTIONS = `Agentset is a RAG-as-a-service platform. This server operates one organization, determined by the API key. Tools mirror the public REST API (api.agentset.ai/v1) one-to-one and are generated from the same API contract. Standard flow to make documents searchable: 1. create_namespace (or list_namespaces to find an existing one). -2. create_upload_urls for local files, then PUT each file's bytes to its presigned URL. Skip this step for raw text, public file URLs, websites, or YouTube videos. +2. create_batch_upload (or create_upload for a single file) for local files, then PUT each file's bytes to its presigned URL. Skip this step for raw text, public file URLs, websites, or YouTube videos. 3. create_ingest_job with the matching payload type (MANAGED_FILE keys from step 2, TEXT, FILE, CRAWL, YOUTUBE, or BATCH). -4. Poll get_ingest_job until the status is COMPLETED (or check list_documents). +4. Poll get_ingest_job_info until the status is COMPLETED (or check list_documents). 5. search for raw chunks, or chat for a generated answer with sources. Multi-tenant partitioning: pass an \`x-tenant-id\` HTTP header on the MCP connection to scope ingestion, documents, search, and chat to that tenant. -Optionally, enable_hosting publishes a shareable chat/search page for a namespace; update_hosting customizes it and set_custom_domain attaches your own domain. +Optionally, enable_hosting publishes a shareable chat/search page for a namespace; update_hosting customizes it and add_domain attaches your own domain. + +IDs are prefixed (org_, ns_, job_, doc_) and tools accept them with or without the prefix. Successful results are the REST response envelope: {"success": true, "data": ..., "pagination"?: ...} (deletes return {"success": true}). Errors are returned as JSON: {"error": {"code", "message"}}.`; + +type ToolExtra = RequestHandlerExtra; + +const toolSuccess = (data: unknown): CallToolResult => ({ + content: [{ type: "text", text: JSON.stringify(data) }], +}); + +const toolError = (code: string, message: string): CallToolResult => ({ + isError: true, + content: [ + { type: "text", text: JSON.stringify({ error: { code, message } }) }, + ], +}); + +/** + * Per-operation adjustments where the MCP transport can't express the REST + * behavior. Configuration only — the tool itself is still generated from the + * procedure contract. + */ +const TOOL_OVERRIDES: Record< + string, + { omitInput?: string[]; forceInput?: Record } +> = { + // a streamed ReadableStream cannot be represented in a tool result + chat: { omitInput: ["stream"], forceInput: { stream: false } }, +}; + +/** listNamespaces → list_namespaces */ +const snakeCase = (value: string) => + value.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`); + +interface ContractRoute { + method?: string; + path?: string; + operationId?: string; + summary?: string; + description?: string; + outputStructure?: string; +} + +/** + * Every procedure that carries an operationId is a published REST operation + * (dashboard-only procedures have no route metadata; the hidden PUT aliases + * have no operationId) — exactly the set the OpenAPI document publishes. + */ +const collectOperations = () => { + const operations: { + name: string; + path: readonly string[]; + route: ContractRoute; + /** + * The zod shape advertised to MCP clients. Top-level refinements don't + * survive shape-spreading, but authoritative validation happens inside + * `call()` against the full procedure schema anyway. + */ + inputShape?: z.ZodRawShape; + }[] = []; + + traverseContractProcedures( + { router: appRouter as AnyRouter, path: [] }, + ({ contract, path }) => { + const def = ( + contract as unknown as { + "~orpc": { route: ContractRoute; inputSchema?: unknown }; + } + )["~orpc"]; + + const operationId = def.route.operationId; + if (!operationId) return; -IDs are prefixed (org_, ns_, job_, doc_) and tools accept them with or without the prefix. Errors are returned as JSON: {"error": {"code", "message"}}.`; + let inputShape: Record | undefined; + if (def.inputSchema instanceof z.ZodObject) { + inputShape = { ...(def.inputSchema.shape as Record) }; + for (const key of TOOL_OVERRIDES[operationId]?.omitInput ?? []) { + delete inputShape[key]; + } + } + + operations.push({ + name: snakeCase(operationId), + path: [...path], + route: def.route, + inputShape, + }); + }, + ); + + return operations; +}; + +/** + * Executes a procedure through the full oRPC middleware chain with the MCP + * connection's credentials — same auth, rate limiting, tenant scoping, and + * input validation as the REST surface, by construction. + */ +const runProcedure = async ( + operation: ReturnType[number], + args: Record | undefined, + extra: ToolExtra, +): Promise => { + const authInfo = extra.authInfo; + const authContext = authInfo?.extra as McpAuthContext | undefined; + if (!authContext?.organizationId || !authInfo?.token) { + return toolError("unauthorized", "Unauthorized: Invalid API key."); + } + + const reqHeaders = new Headers({ + authorization: `Bearer ${authInfo.token}`, + }); + if (authContext.tenantId) { + reqHeaders.set("x-tenant-id", authContext.tenantId); + } + + const context: ApiContext = { reqHeaders, resHeaders: {}, analytics: {} }; + + try { + const procedure = getRouter(appRouter as AnyRouter, operation.path); + if (!procedure) { + return toolError("internal_server_error", "Unknown tool."); + } + + const input = { + ...args, + ...TOOL_OVERRIDES[operation.route.operationId ?? ""]?.forceInput, + }; + + let result: unknown = await call(procedure as AnyProcedure, input, { + context, + }); + + // detailed-output procedures (chat) wrap the payload in {status, body} + if (operation.route.outputStructure === "detailed") { + result = (result as { body: unknown }).body; + } + + // 204-style deletes resolve void; give the agent an explicit ack + return toolSuccess(result === undefined ? { success: true } : result); + } catch (error) { + // same mapping as the REST error envelope (AgentsetApiError, zod 422s, + // Prisma not-found), minus the doc_url + const { body } = toLegacyErrorParts(error); + return toolError(body.error.code, body.error.message); + } +}; export const registerTools = (server: McpServer) => { - registerOrganizationTools(server); - registerApiKeyTools(server); - registerNamespaceTools(server); - registerIngestionTools(server); - registerDocumentTools(server); - registerSearchTools(server); - registerChatTools(server); - registerHostingTools(server); - registerWebhookTools(server); + for (const operation of collectOperations()) { + const method = operation.route.method ?? "POST"; + + server.registerTool( + operation.name, + { + title: operation.route.summary, + description: operation.route.description, + ...(operation.inputShape && { inputSchema: operation.inputShape }), + annotations: { + readOnlyHint: method === "GET", + destructiveHint: method === "DELETE", + idempotentHint: method !== "POST", + }, + }, + (operation.inputShape + ? (args: Record, extra: ToolExtra) => + runProcedure(operation, args, extra) + : (extra: ToolExtra) => + runProcedure(operation, undefined, extra)) as never, + ); + } }; diff --git a/apps/web/src/lib/mcp/run-tool.ts b/apps/web/src/lib/mcp/run-tool.ts deleted file mode 100644 index a4df10d2..00000000 --- a/apps/web/src/lib/mcp/run-tool.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { McpAuthContext } from "@/lib/mcp/auth"; -import { AgentsetApiError, handleApiError } from "@/lib/api/errors"; -import { getNamespace } from "@/lib/api/handler/namespace"; -import { ratelimit } from "@/lib/api/rate-limit"; - -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { - CallToolResult, - ServerNotification, - ServerRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import { normalizeId } from "@agentset/utils"; - -type ToolExtra = RequestHandlerExtra; - -export const toolSuccess = (data: unknown): CallToolResult => ({ - content: [{ type: "text", text: JSON.stringify(data) }], -}); - -export const toolError = (code: string, message: string): CallToolResult => ({ - isError: true, - content: [ - { type: "text", text: JSON.stringify({ error: { code, message } }) }, - ], -}); - -// shared wrapper for every MCP tool: applies the same per-org rate limit as the -// REST v1 handlers and maps business errors to tool error results (never a -// protocol-level failure). -export const runTool = async ( - extra: ToolExtra, - handler: (ctx: McpAuthContext) => Promise, -): Promise => { - const ctx = extra.authInfo?.extra as McpAuthContext | undefined; - if (!ctx?.organizationId) { - return toolError("unauthorized", "Unauthorized: Invalid API key."); - } - - try { - const { success, reset } = await ratelimit( - ctx.organization.apiRatelimit, - "1 m", - ).limit(ctx.organizationId); - - if (!success) { - return toolError( - "rate_limit_exceeded", - `Too many requests. Retry after ${new Date(reset).toISOString()}.`, - ); - } - - const data = await handler(ctx); - return toolSuccess(data); - } catch (error) { - // maps AgentsetApiError, ZodError, and Prisma not-found errors the same - // way the REST error handler does - const { error: apiError } = handleApiError(error); - return toolError(apiError.code, apiError.message); - } -}; - -// resolves a namespace like withNamespaceApiHandler does: normalize the ns_ -// prefix, must be ACTIVE and belong to the key's organization -export const resolveNamespace = async ( - ctx: McpAuthContext, - rawNamespaceId: string, -) => { - const namespaceId = normalizeId(rawNamespaceId, "ns_"); - if (!namespaceId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid namespace ID.", - }); - } - - const namespace = await getNamespace({ - namespaceId, - organizationId: ctx.organizationId, - }); - - if (!namespace) { - throw new AgentsetApiError({ - code: "unauthorized", - message: "Unauthorized: You don't have access to this namespace.", - }); - } - - return namespace; -}; diff --git a/apps/web/src/lib/mcp/schemas.ts b/apps/web/src/lib/mcp/schemas.ts deleted file mode 100644 index 59096568..00000000 --- a/apps/web/src/lib/mcp/schemas.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from "zod/v4"; - -export const namespaceIdSchema = z - .string() - .describe( - "The ID of the namespace to operate on (e.g. `ns_xxx`). Use list_namespaces to find it.", - ); diff --git a/apps/web/src/lib/mcp/tools/api-keys.ts b/apps/web/src/lib/mcp/tools/api-keys.ts deleted file mode 100644 index e15b23e6..00000000 --- a/apps/web/src/lib/mcp/tools/api-keys.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { runTool } from "@/lib/mcp/run-tool"; -import { - ApiKeySchema, - createApiKeyBodySchema, - CreatedApiKeySchema, -} from "@/schemas/api/api-key"; -import { createApiKey } from "@/services/api-key/create"; -import { deleteApiKey } from "@/services/api-key/delete"; -import { listApiKeys } from "@/services/api-key/list"; -import { z } from "zod/v4"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { prefixId } from "@agentset/utils"; - -export const registerApiKeyTools = (server: McpServer) => { - server.registerTool( - "list_api_keys", - { - title: "List API keys", - description: - "List the organization's API keys (label, scope, and timestamps). The key material itself is never returned; it is only visible once, when a key is created.", - }, - async (extra) => - runTool(extra, async (ctx) => { - const apiKeys = await listApiKeys({ - organizationId: ctx.organizationId, - }); - - return apiKeys.map((apiKey) => - ApiKeySchema.parse({ - ...apiKey, - organizationId: prefixId(apiKey.organizationId, "org_"), - }), - ); - }), - ); - - server.registerTool( - "create_api_key", - { - title: "Create API key", - description: - "Create a new API key for the organization. The response includes the full key (starts with `agentset_`) exactly once — store it securely, it cannot be retrieved again.", - inputSchema: createApiKeyBodySchema.shape, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const apiKey = await createApiKey({ - organizationId: ctx.organizationId, - label: args.label, - scope: args.scope, - }); - - return CreatedApiKeySchema.parse({ - ...apiKey, - organizationId: prefixId(apiKey.organizationId, "org_"), - }); - }), - ); - - server.registerTool( - "revoke_api_key", - { - title: "Revoke API key", - description: - "Permanently revoke (delete) an API key by its ID. Use list_api_keys to find the ID. Revoking the key you are currently authenticated with will break this connection.", - inputSchema: { - keyId: z.string().describe("The ID of the API key to revoke."), - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - if (!args.keyId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid API key ID.", - }); - } - - // scoped to the organization; a missing key throws P2025 which maps to not_found - const apiKey = await deleteApiKey({ - id: args.keyId, - organizationId: ctx.organizationId, - }); - - return { deleted: true, id: apiKey.id }; - }), - ); -}; diff --git a/apps/web/src/lib/mcp/tools/chat.ts b/apps/web/src/lib/mcp/tools/chat.ts deleted file mode 100644 index 5a5410d4..00000000 --- a/apps/web/src/lib/mcp/tools/chat.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - checkSearchLimit, - incrementOrganizationSearchUsage, -} from "@/lib/api/usage"; -import { generateChat } from "@/lib/chat"; -import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; -import { namespaceIdSchema } from "@/lib/mcp/schemas"; -import { - chatMessageSchema, - chatOptionsSchema, - chatSchema, -} from "@/schemas/api/chat"; -import { toModelMessages } from "@/services/chat"; -import { z } from "zod/v4"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - -export const registerChatTools = (server: McpServer) => { - server.registerTool( - "chat", - { - title: "Chat with a namespace", - description: - "Generate a grounded (RAG) answer from the documents of a namespace, with the retrieved chunks returned as sources. Modes: `normal` (single retrieval, fast), `agentic` (the model generates and evaluates its own search queries, slower but more thorough), and `deepResearch` (iterative research pipeline — slow, can take a minute or more, and does not return sources). Counts toward the organization's search quota (agentic mode counts one search per query it runs). Non-streaming; for streaming use the REST API.", - inputSchema: { - namespaceId: namespaceIdSchema, - messages: z - .array(chatMessageSchema) - .min(1) - .describe( - "The conversation so far. The last message must be from the user, it will be used as the query.", - ), - ...chatOptionsSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - checkSearchLimit(ctx.organization); - - const namespace = await resolveNamespace(ctx, args.namespaceId); - const { namespaceId: _namespaceId, ...rest } = args; - - // re-parse with the REST chat schema to apply the same cross-field - // validation (e.g. rerankLimit <= topK) - const body = await chatSchema.parseAsync({ ...rest, stream: false }); - - const { text, sources } = await generateChat({ - namespace, - tenantId: ctx.tenantId, - messages: toModelMessages(body.messages), - options: body, - onUsageIncrement: (queries) => { - incrementOrganizationSearchUsage(ctx.organizationId, queries); - }, - }); - - return { - message: { role: "assistant", content: text }, - sources, - }; - }), - ); -}; diff --git a/apps/web/src/lib/mcp/tools/documents.ts b/apps/web/src/lib/mcp/tools/documents.ts deleted file mode 100644 index d6284415..00000000 --- a/apps/web/src/lib/mcp/tools/documents.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; -import { namespaceIdSchema } from "@/lib/mcp/schemas"; -import { DocumentSchema } from "@/schemas/api/document"; -import { paginationSchema } from "@/schemas/api/pagination"; -import { queueDocumentDeletion } from "@/services/documents/delete"; -import { - getDocumentChunksDownloadUrl, - getDocumentFileDownloadUrl, -} from "@/services/documents/download"; -import { getDocumentOrThrow } from "@/services/documents/get"; -import { getPaginationArgs, paginateResults } from "@/services/pagination"; -import { z } from "zod/v4"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { Document } from "@agentset/db"; -import { DocumentStatusSchema } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { normalizeId, prefixId } from "@agentset/utils"; - -const documentIdSchema = z - .string() - .describe("The ID of the document (e.g. `doc_xxx`)."); - -const toDocumentResponse = (doc: Document) => - DocumentSchema.parse({ - ...doc, - id: prefixId(doc.id, "doc_"), - ingestJobId: prefixId(doc.ingestJobId, "job_"), - }); - -export const registerDocumentTools = (server: McpServer) => { - server.registerTool( - "list_documents", - { - title: "List documents", - description: - "List the documents of a namespace with cursor pagination. Pass the returned `pagination.nextCursor` as `cursor` to fetch the next page. Optionally filter by statuses or by the ingest job that created them.", - inputSchema: { - namespaceId: namespaceIdSchema, - statuses: z - .array(DocumentStatusSchema) - .optional() - .describe("Statuses to filter by."), - orderBy: z.enum(["createdAt"]).optional().default("createdAt"), - order: z.enum(["asc", "desc"]).optional().default("desc"), - ingestJobId: z - .string() - .optional() - .describe("The ingest job ID to filter documents by."), - ...paginationSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - - const { where, ...paginationArgs } = getPaginationArgs( - args, - { - orderBy: args.orderBy, - order: args.order, - }, - "doc_", - ); - - const documents = await db.document.findMany({ - where: { - tenantId: ctx.tenantId, - namespaceId: namespace.id, - ...(args.ingestJobId && { - ingestJobId: normalizeId(args.ingestJobId, "job_"), - }), - ...(args.statuses && - args.statuses.length > 0 && { status: { in: args.statuses } }), - ...where, - }, - ...paginationArgs, - }); - - const paginated = paginateResults( - args, - documents.map(toDocumentResponse), - ); - - return { - records: paginated.records, - pagination: paginated.pagination, - }; - }), - ); - - server.registerTool( - "get_document", - { - title: "Get document", - description: - "Get a document by ID, including its status, source, and chunk/token/page counts.", - inputSchema: { - namespaceId: namespaceIdSchema, - documentId: documentIdSchema, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - - const doc = await getDocumentOrThrow({ - namespaceId: namespace.id, - documentId: args.documentId, - tenantId: ctx.tenantId, - }); - - return toDocumentResponse(doc); - }), - ); - - server.registerTool( - "delete_document", - { - title: "Delete document", - description: - "Delete a single document and its vectors. Deletion is asynchronous — the document status transitions to QUEUED_FOR_DELETE.", - inputSchema: { - namespaceId: namespaceIdSchema, - documentId: documentIdSchema, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - - const data = await queueDocumentDeletion({ - namespaceId: namespace.id, - organizationId: namespace.organizationId, - documentId: args.documentId, - tenantId: ctx.tenantId, - }); - - return toDocumentResponse(data); - }), - ); - - server.registerTool( - "get_document_chunks_download_url", - { - title: "Get document chunks download URL", - description: - "Get a presigned URL to download the parsed chunks of a document as JSON. Only available once the document status is COMPLETED. The URL is temporary — download it promptly.", - inputSchema: { - namespaceId: namespaceIdSchema, - documentId: documentIdSchema, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - - return getDocumentChunksDownloadUrl({ - namespaceId: namespace.id, - documentId: args.documentId, - tenantId: ctx.tenantId, - }); - }), - ); - - server.registerTool( - "get_document_file_download_url", - { - title: "Get document file download URL", - description: - "Get a presigned URL to download the original file of a document. Only available for documents ingested as managed files (uploaded via create_upload_urls). The URL is temporary — download it promptly.", - inputSchema: { - namespaceId: namespaceIdSchema, - documentId: documentIdSchema, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - - return getDocumentFileDownloadUrl({ - namespaceId: namespace.id, - documentId: args.documentId, - tenantId: ctx.tenantId, - }); - }), - ); -}; diff --git a/apps/web/src/lib/mcp/tools/hosting.ts b/apps/web/src/lib/mcp/tools/hosting.ts deleted file mode 100644 index f78d9361..00000000 --- a/apps/web/src/lib/mcp/tools/hosting.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; -import { namespaceIdSchema } from "@/lib/mcp/schemas"; -import { - addDomainSchema, - DomainSchema, - DomainStatusSchema, - HostingSchema, - updateHostingSchema, -} from "@/schemas/api/hosting"; -import { addDomain } from "@/services/domains/add"; -import { checkDomainStatus } from "@/services/domains/check-status"; -import { removeDomain } from "@/services/domains/remove"; -import { deleteHosting } from "@/services/hosting/delete"; -import { enableHosting } from "@/services/hosting/enable"; -import { getHosting } from "@/services/hosting/get"; -import { updateHosting } from "@/services/hosting/update"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { prefixId } from "@agentset/utils"; - -type HostingResult = NonNullable>>; - -const toHostingResponse = ( - hosting: Omit & { - domain?: HostingResult["domain"]; - }, -) => - HostingSchema.parse({ - ...hosting, - namespaceId: prefixId(hosting.namespaceId, "ns_"), - }); - -const getHostingOrThrow = async (namespaceId: string) => { - const hosting = await getHosting({ namespaceId }); - - if (!hosting) { - throw new AgentsetApiError({ - code: "not_found", - message: "Hosting is not enabled for this namespace.", - }); - } - - return hosting; -}; - -export const registerHostingTools = (server: McpServer) => { - server.registerTool( - "get_hosting", - { - title: "Get hosting", - description: - "Get the hosted chat/search interface configuration of a namespace, including its slug and custom domain (if any). Returns not_found when hosting has not been enabled — use enable_hosting first.", - inputSchema: { namespaceId: namespaceIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const hosting = await getHosting({ namespaceId: namespace.id }); - - if (!hosting) { - throw new AgentsetApiError({ - code: "not_found", - message: "Hosting not found for this namespace.", - }); - } - - return toHostingResponse(hosting); - }), - ); - - server.registerTool( - "enable_hosting", - { - title: "Enable hosting", - description: - "Enable a hosted chat/search interface for a namespace. A unique slug is generated automatically; customize the page afterwards with update_hosting and optionally attach a custom domain with set_custom_domain.", - inputSchema: { namespaceId: namespaceIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const hosting = await enableHosting({ namespaceId: namespace.id }); - - return toHostingResponse(hosting); - }), - ); - - server.registerTool( - "update_hosting", - { - title: "Update hosting", - description: - "Update the hosted interface of a namespace: title, slug, logo, Open Graph metadata, system prompt, example questions/search queries, welcome message, access protection (allowed emails/domains), search toggle, and retrieval settings (rerank model/limit, LLM model, topK). Hosting must be enabled first.", - inputSchema: { - namespaceId: namespaceIdSchema, - ...updateHostingSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const { namespaceId: _namespaceId, ...input } = args; - - const updatedHosting = await updateHosting({ - namespaceId: namespace.id, - input, - }); - - return toHostingResponse(updatedHosting); - }), - ); - - server.registerTool( - "disable_hosting", - { - title: "Disable hosting", - description: - "Disable and delete the hosted interface of a namespace, removing its custom domain (if any). The namespace and its documents are not affected.", - inputSchema: { namespaceId: namespaceIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - await deleteHosting({ namespaceId: namespace.id }); - - return { deleted: true }; - }), - ); - - server.registerTool( - "set_custom_domain", - { - title: "Set custom domain", - description: - "Attach a custom domain (e.g. docs.example.com) to the hosted interface of a namespace. Hosting must be enabled first, and only one domain can be set at a time. After setting it, call get_domain_status to retrieve the DNS records required for verification. Errors clearly if the deployment has no Vercel credentials configured.", - inputSchema: { - namespaceId: namespaceIdSchema, - ...addDomainSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const hosting = await getHostingOrThrow(namespace.id); - - const domain = await addDomain({ - hostingId: hosting.id, - domain: args.domain, - }); - - return DomainSchema.parse(domain); - }), - ); - - server.registerTool( - "get_domain_status", - { - title: "Get domain status", - description: - "Check the verification/configuration status of the custom domain attached to a namespace's hosted interface. When the status is 'Pending Verification', the response lists the DNS records to set; when there are conflicts, it lists the conflicting records to remove. Errors clearly if the deployment has no Vercel credentials configured.", - inputSchema: { namespaceId: namespaceIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const hosting = await getHostingOrThrow(namespace.id); - - if (!hosting.domain) { - throw new AgentsetApiError({ - code: "not_found", - message: "No custom domain is set for this namespace.", - }); - } - - const { status, response } = await checkDomainStatus({ - hostingId: hosting.id, - }); - - // the Vercel responses omit fields when the domain is not found, - // the schema defaults fill those in - return DomainStatusSchema.parse({ - domain: hosting.domain.slug, - status, - verified: status === "Valid Configuration", - apexName: response.domainJson.apexName, - verification: response.domainJson.verification, - conflicts: response.configJson.conflicts, - misconfigured: response.configJson.misconfigured, - }); - }), - ); - - server.registerTool( - "remove_custom_domain", - { - title: "Remove custom domain", - description: - "Detach the custom domain from a namespace's hosted interface. The hosted interface remains available on its Agentset slug. Errors clearly if the deployment has no Vercel credentials configured.", - inputSchema: { namespaceId: namespaceIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const hosting = await getHostingOrThrow(namespace.id); - - await removeDomain({ hostingId: hosting.id }); - - return { deleted: true }; - }), - ); -}; diff --git a/apps/web/src/lib/mcp/tools/ingestion.ts b/apps/web/src/lib/mcp/tools/ingestion.ts deleted file mode 100644 index 53072ca6..00000000 --- a/apps/web/src/lib/mcp/tools/ingestion.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; -import { namespaceIdSchema } from "@/lib/mcp/schemas"; -import { - createIngestJobSchema, - IngestJobSchema, -} from "@/schemas/api/ingest-job"; -import { paginationSchema } from "@/schemas/api/pagination"; -import { batchUploadSchema, UploadResultSchema } from "@/schemas/api/upload"; -import { createIngestJob } from "@/services/ingest-jobs/create"; -import { deleteIngestJob } from "@/services/ingest-jobs/delete"; -import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; -import { getPaginationArgs, paginateResults } from "@/services/pagination"; -import { createBatchUpload } from "@/services/uploads"; -import { z } from "zod/v4"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { IngestJob } from "@agentset/db"; -import { IngestJobStatusSchema } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { isFreePlan } from "@agentset/stripe/plans"; -import { normalizeId, prefixId } from "@agentset/utils"; - -const jobIdSchema = z - .string() - .describe("The ID of the ingest job (e.g. `job_xxx`)."); - -const toIngestJobResponse = (job: IngestJob) => - IngestJobSchema.parse({ - ...job, - id: prefixId(job.id, "job_"), - namespaceId: prefixId(job.namespaceId, "ns_"), - }); - -const normalizeJobId = (rawJobId: string) => { - const jobId = normalizeId(rawJobId, "job_"); - if (!jobId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid job id", - }); - } - - return jobId; -}; - -export const registerIngestionTools = (server: McpServer) => { - server.registerTool( - "create_upload_urls", - { - title: "Create upload URLs", - description: - "Create presigned upload URLs for one or more files (two-step upload flow, step 1). For each file you get back a `url` and a `key`: make an HTTP PUT request to the `url` with the raw file bytes as the body and the same `Content-Type` header you declared here, then pass the `key` in a `MANAGED_FILE` payload to create_ingest_job (step 2). URLs expire, so upload promptly.", - inputSchema: { - namespaceId: namespaceIdSchema, - files: batchUploadSchema.shape.files.describe( - "The files to create upload URLs for (1-100).", - ), - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - - const result = await createBatchUpload({ - namespaceId: namespace.id, - plan: ctx.organization.plan, - files: args.files, - }); - - if (!result.success) { - throw new AgentsetApiError({ - code: - result.code === "file_too_large" - ? "rate_limit_exceeded" - : "internal_server_error", - message: result.error, - }); - } - - return z.array(UploadResultSchema).parse(result.data); - }), - ); - - server.registerTool( - "create_ingest_job", - { - title: "Create ingest job", - description: - "Ingest content into a namespace so it becomes searchable. Payload variants: `TEXT` (raw text), `FILE` (a publicly accessible fileUrl), `MANAGED_FILE` (a key from create_upload_urls after PUT-ing the file), `CRAWL` (a website URL), `YOUTUBE` (a video URL), or `BATCH` (a list of FILE/MANAGED_FILE items). Ingestion is asynchronous: poll get_ingest_job until the status is COMPLETED before searching. If a tenant is set via the x-tenant-id header, the documents are partitioned to that tenant.", - inputSchema: { - namespaceId: namespaceIdSchema, - ...createIngestJobSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const { namespaceId: _namespaceId, ...body } = args; - - if (isFreePlan(ctx.organization.plan) && body.config?.mode === "accurate") { - throw new AgentsetApiError({ - code: "bad_request", - message: "Accurate mode requires a pro subscription.", - }); - } - - const job = await createIngestJob({ - data: body, - organization: { - id: ctx.organizationId, - plan: ctx.organization.plan, - totalPages: ctx.organization.totalPages, - pagesLimit: ctx.organization.pagesLimit, - }, - namespaceId: namespace.id, - tenantId: ctx.tenantId, - }); - - return toIngestJobResponse(job); - }), - ); - - server.registerTool( - "list_ingest_jobs", - { - title: "List ingest jobs", - description: - "List the ingest jobs of a namespace with cursor pagination. Pass the returned `pagination.nextCursor` as `cursor` to fetch the next page. Optionally filter by statuses.", - inputSchema: { - namespaceId: namespaceIdSchema, - statuses: z - .array(IngestJobStatusSchema) - .optional() - .describe("Statuses to filter by."), - orderBy: z.enum(["createdAt"]).optional().default("createdAt"), - order: z.enum(["asc", "desc"]).optional().default("desc"), - ...paginationSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - - const { where, ...paginationArgs } = getPaginationArgs( - args, - { - orderBy: args.orderBy, - order: args.order, - }, - "job_", - ); - - const ingestJobs = await db.ingestJob.findMany({ - where: { - namespaceId: namespace.id, - tenantId: ctx.tenantId, - ...(args.statuses && - args.statuses.length > 0 && { - status: { in: args.statuses }, - }), - ...where, - }, - ...paginationArgs, - }); - - const paginated = paginateResults( - args, - ingestJobs.map(toIngestJobResponse), - ); - - return { - records: paginated.records, - pagination: paginated.pagination, - }; - }), - ); - - server.registerTool( - "get_ingest_job", - { - title: "Get ingest job", - description: - "Get an ingest job by ID, including its status (QUEUED, PRE_PROCESSING, PROCESSING, COMPLETED, FAILED, ...) and error message if it failed. Poll this after create_ingest_job until the status is COMPLETED.", - inputSchema: { namespaceId: namespaceIdSchema, jobId: jobIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const jobId = normalizeJobId(args.jobId); - - const data = await db.ingestJob.findUnique({ - where: { - id: jobId, - namespaceId: namespace.id, - }, - }); - - if (!data) { - throw new AgentsetApiError({ - code: "not_found", - message: "Ingest job not found", - }); - } - - return toIngestJobResponse(data); - }), - ); - - server.registerTool( - "delete_ingest_job", - { - title: "Delete ingest job", - description: - "Delete an ingest job and all documents it created. Deletion is asynchronous — the job status transitions to QUEUED_FOR_DELETE.", - inputSchema: { namespaceId: namespaceIdSchema, jobId: jobIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const jobId = normalizeJobId(args.jobId); - - const data = await deleteIngestJob({ - jobId, - namespaceId: namespace.id, - organizationId: namespace.organizationId, - }); - - return toIngestJobResponse(data); - }), - ); - - server.registerTool( - "reingest_job", - { - title: "Re-ingest job", - description: - "Re-run an existing ingest job (e.g. after a failure or to refresh crawled content). The job must not currently be processing or deleting. Poll get_ingest_job for the new status.", - inputSchema: { namespaceId: namespaceIdSchema, jobId: jobIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const jobId = normalizeJobId(args.jobId); - - const job = await reIngestJob({ - jobId, - namespaceId: namespace.id, - organizationId: namespace.organizationId, - plan: ctx.organization.plan, - }); - - return { id: prefixId(job.id, "job_") }; - }), - ); -}; diff --git a/apps/web/src/lib/mcp/tools/namespaces.ts b/apps/web/src/lib/mcp/tools/namespaces.ts deleted file mode 100644 index 20508be6..00000000 --- a/apps/web/src/lib/mcp/tools/namespaces.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; -import { namespaceIdSchema } from "@/lib/mcp/schemas"; -import { - createNamespaceSchema, - NamespaceSchema, - updateNamespaceSchema, -} from "@/schemas/api/namespace"; -import { createNamespace } from "@/services/namespaces/create"; -import { deleteNamespace } from "@/services/namespaces/delete"; -import { updateNamespace } from "@/services/namespaces/update"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { Namespace } from "@agentset/db"; -import { NamespaceStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { prefixId } from "@agentset/utils"; - -const toNamespaceResponse = (namespace: Namespace) => - NamespaceSchema.parse({ - ...namespace, - id: prefixId(namespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }); - -export const registerNamespaceTools = (server: McpServer) => { - server.registerTool( - "list_namespaces", - { - title: "List namespaces", - description: - "List all active namespaces in the organization. A namespace is an isolated RAG index (documents + vector store). Most other tools require a namespaceId from this list.", - }, - async (extra) => - runTool(extra, async (ctx) => { - const namespaces = await db.namespace.findMany({ - where: { - organizationId: ctx.organizationId, - status: NamespaceStatus.ACTIVE, - }, - orderBy: { - createdAt: "asc", - }, - }); - - return namespaces.map(toNamespaceResponse); - }), - ); - - server.registerTool( - "get_namespace", - { - title: "Get namespace", - description: - "Get a namespace by ID, including its embedding and vector store configuration.", - inputSchema: { namespaceId: namespaceIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - return toNamespaceResponse(namespace); - }), - ); - - server.registerTool( - "create_namespace", - { - title: "Create namespace", - description: - "Create a new namespace. Omit embeddingConfig and vectorStoreConfig to use Agentset's managed defaults (recommended); if provided, they cannot be changed after creation. After creating a namespace, upload files with create_upload_urls and ingest them with create_ingest_job.", - inputSchema: createNamespaceSchema.shape, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await createNamespace({ - organizationId: ctx.organizationId, - data: args, - }); - - return toNamespaceResponse(namespace); - }), - ); - - server.registerTool( - "update_namespace", - { - title: "Update namespace", - description: - "Update a namespace's name and/or slug. At least one field must be provided. Embedding and vector store configs cannot be changed.", - inputSchema: { - namespaceId: namespaceIdSchema, - ...updateNamespaceSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - const { namespaceId: _namespaceId, ...data } = args; - - const updatedNamespace = await updateNamespace({ - namespaceId: namespace.id, - organizationId: namespace.organizationId, - data, - }); - - return toNamespaceResponse(updatedNamespace); - }), - ); - - server.registerTool( - "delete_namespace", - { - title: "Delete namespace", - description: - "Delete a namespace and all of its documents and vectors. This is irreversible and processed asynchronously — the namespace immediately stops appearing in list_namespaces.", - inputSchema: { namespaceId: namespaceIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const namespace = await resolveNamespace(ctx, args.namespaceId); - await deleteNamespace({ namespaceId: namespace.id }); - - return { deleted: true, id: prefixId(namespace.id, "ns_") }; - }), - ); -}; diff --git a/apps/web/src/lib/mcp/tools/organization.ts b/apps/web/src/lib/mcp/tools/organization.ts deleted file mode 100644 index aaf1f1f2..00000000 --- a/apps/web/src/lib/mcp/tools/organization.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { runTool } from "@/lib/mcp/run-tool"; -import { - OrganizationMembersSchema, - updateOrganizationSchema, -} from "@/schemas/api/organization"; -import { - getOrganization, - toOrganizationResponse, -} from "@/services/organizations/get"; -import { getOrganizationMembers } from "@/services/organizations/members"; -import { updateOrganization } from "@/services/organizations/update"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - -export const registerOrganizationTools = (server: McpServer) => { - server.registerTool( - "get_organization", - { - title: "Get organization", - description: - "Get the organization that owns the current API key, including its plan, search usage/limits, page usage/limits, and API rate limit. Use this to check remaining quota before running searches or ingesting documents.", - }, - async (extra) => - runTool(extra, async (ctx) => { - const org = await getOrganization({ - organizationId: ctx.organizationId, - }); - - if (!org) { - throw new AgentsetApiError({ - code: "not_found", - message: "Organization not found.", - }); - } - - return toOrganizationResponse(org); - }), - ); - - server.registerTool( - "update_organization", - { - title: "Update organization", - description: - "Update the organization's name and/or slug. At least one field must be provided. The slug must be unique across Agentset.", - inputSchema: updateOrganizationSchema.shape, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - // the schema-level refine doesn't survive .shape spreading - if (args.name === undefined && args.slug === undefined) { - throw new AgentsetApiError({ - code: "bad_request", - message: "At least one field must be provided", - }); - } - - const updatedOrganization = await updateOrganization({ - organizationId: ctx.organizationId, - name: args.name, - slug: args.slug, - }); - - return toOrganizationResponse(updatedOrganization); - }), - ); - - server.registerTool( - "list_members", - { - title: "List organization members", - description: - "List the members of the organization and its pending invitations.", - }, - async (extra) => - runTool(extra, async (ctx) => { - const members = await getOrganizationMembers({ - organizationId: ctx.organizationId, - }); - - if (!members) { - throw new AgentsetApiError({ - code: "not_found", - message: "Organization not found.", - }); - } - - return OrganizationMembersSchema.parse(members); - }), - ); -}; diff --git a/apps/web/src/lib/mcp/tools/search.ts b/apps/web/src/lib/mcp/tools/search.ts deleted file mode 100644 index 213b2e0a..00000000 --- a/apps/web/src/lib/mcp/tools/search.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - checkSearchLimit, - incrementOrganizationSearchUsage, -} from "@/lib/api/usage"; -import { resolveNamespace, runTool } from "@/lib/mcp/run-tool"; -import { namespaceIdSchema } from "@/lib/mcp/schemas"; -import { - baseQueryVectorStoreSchema, - queryVectorStoreSchema, -} from "@/schemas/api/query"; -import { searchNamespace } from "@/services/search"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - -export const registerSearchTools = (server: McpServer) => { - server.registerTool( - "search", - { - title: "Search a namespace", - description: - "Run a semantic (or keyword) search over the documents of a namespace and get back the most relevant chunks. Documents must be ingested (create_ingest_job) and COMPLETED first. Counts toward the organization's search quota. If a tenant is set via the x-tenant-id header, only that tenant's documents are searched.", - inputSchema: { - namespaceId: namespaceIdSchema, - ...baseQueryVectorStoreSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - checkSearchLimit(ctx.organization); - - const namespace = await resolveNamespace(ctx, args.namespaceId); - const { namespaceId: _namespaceId, ...rest } = args; - - // re-parse to apply the same cross-field validation as the REST route - const body = await queryVectorStoreSchema.parseAsync(rest); - - const results = await searchNamespace({ - namespace, - tenantId: ctx.tenantId, - options: body, - }); - - incrementOrganizationSearchUsage(ctx.organizationId, 1); - - return results; - }), - ); -}; diff --git a/apps/web/src/lib/mcp/tools/webhooks.ts b/apps/web/src/lib/mcp/tools/webhooks.ts deleted file mode 100644 index 197f46d8..00000000 --- a/apps/web/src/lib/mcp/tools/webhooks.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { AgentsetApiError } from "@/lib/api/errors"; -import { runTool } from "@/lib/mcp/run-tool"; -import { - createWebhookSchema, - updateWebhookSchema, - WebhookDetailsSchema, - WebhookSchema, - WebhookSummarySchema, -} from "@/schemas/api/webhook"; -import { createWebhook } from "@/services/webhooks/create"; -import { deleteWebhook } from "@/services/webhooks/delete"; -import { getWebhook } from "@/services/webhooks/get"; -import { listWebhooks } from "@/services/webhooks/list"; -import { requireWebhooksPlan } from "@/services/webhooks/plan"; -import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; -import { applyWebhookUpdate } from "@/services/webhooks/update"; -import { z } from "zod/v4"; - -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { WebhookProps } from "@agentset/webhooks"; -import { normalizeId, prefixId } from "@agentset/utils"; - -const webhookIdSchema = z - .string() - .describe( - "The ID of the webhook to operate on (e.g. `wh_xxx`). Use list_webhooks to find it.", - ); - -const webhookNotFoundError = () => - new AgentsetApiError({ - code: "not_found", - message: "Webhook not found.", - }); - -// webhook IDs are stored with the wh_ prefix; only namespace IDs need prefixing -const prefixNamespaceIds = >( - webhook: T, -) => ({ - ...webhook, - namespaceIds: (webhook.namespaceIds ?? []).map((id) => prefixId(id, "ns_")), -}); - -export const registerWebhookTools = (server: McpServer) => { - server.registerTool( - "list_webhooks", - { - title: "List webhooks", - description: - "List the organization's webhooks (name, URL, triggers, enabled state, and namespace scope). Signing secrets are not included — use get_webhook to retrieve one.", - }, - async (extra) => - runTool(extra, async (ctx) => { - const webhooks = await listWebhooks({ - organizationId: ctx.organizationId, - }); - - return webhooks.map((webhook) => - WebhookSummarySchema.parse(prefixNamespaceIds(webhook)), - ); - }), - ); - - server.registerTool( - "get_webhook", - { - title: "Get webhook", - description: - "Get a webhook by its ID, including its signing secret (`whsec_...`), delivery health (consecutive failures, last failure time), and creation date. Use list_webhooks to find the ID.", - inputSchema: { webhookId: webhookIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const webhook = await getWebhook({ - organizationId: ctx.organizationId, - webhookId: args.webhookId, - }); - - if (!webhook) { - throw webhookNotFoundError(); - } - - return WebhookDetailsSchema.parse(prefixNamespaceIds(webhook)); - }), - ); - - server.registerTool( - "create_webhook", - { - title: "Create webhook", - description: - "Create a webhook that receives document and ingest-job lifecycle events via HTTP POST. Scope it to specific namespaces with namespaceIds, or omit them to receive events for the whole organization. The response includes the signing secret. Requires the Pro plan or above.", - inputSchema: createWebhookSchema.shape, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - requireWebhooksPlan(ctx.organization.plan); - - const webhook = await createWebhook({ - ...args, - namespaceIds: args.namespaceIds?.map((id) => normalizeId(id, "ns_")), - organizationId: ctx.organizationId, - }); - - return WebhookSchema.parse(prefixNamespaceIds(webhook)); - }), - ); - - server.registerTool( - "update_webhook", - { - title: "Update webhook", - description: - "Update a webhook's name, URL, triggers, or namespace scope, and/or enable/disable it. At least one field must be provided. Field updates require the Pro plan or above; toggling `enabled` alone does not. Re-enabling a disabled webhook resets its failure count.", - inputSchema: { - webhookId: webhookIdSchema, - ...updateWebhookSchema.shape, - }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const { webhookId, ...body } = args; - - const webhook = await applyWebhookUpdate({ - organizationId: ctx.organizationId, - plan: ctx.organization.plan, - webhookId, - ...body, - }); - - return WebhookSchema.parse(prefixNamespaceIds(webhook)); - }), - ); - - server.registerTool( - "delete_webhook", - { - title: "Delete webhook", - description: - "Permanently delete a webhook by its ID. Events stop being delivered immediately. Use list_webhooks to find the ID.", - inputSchema: { webhookId: webhookIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const webhook = await deleteWebhook({ - organizationId: ctx.organizationId, - webhookId: args.webhookId, - }); - - if (!webhook) { - throw webhookNotFoundError(); - } - - return { deleted: true, id: webhook.id }; - }), - ); - - server.registerTool( - "regenerate_webhook_secret", - { - title: "Regenerate webhook secret", - description: - "Generate a new signing secret for a webhook, invalidating the old one immediately. The response includes the new secret (`whsec_...`) — update the receiving endpoint's signature verification before deliveries fail.", - inputSchema: { webhookId: webhookIdSchema }, - }, - async (args, extra) => - runTool(extra, async (ctx) => { - const webhook = await regenerateWebhookSecret({ - organizationId: ctx.organizationId, - webhookId: args.webhookId, - }); - - if (!webhook) { - throw webhookNotFoundError(); - } - - return WebhookSchema.parse(prefixNamespaceIds(webhook)); - }), - ); -}; diff --git a/apps/web/src/lib/orpc.ts b/apps/web/src/lib/orpc.ts index b8462122..1eb872e7 100644 --- a/apps/web/src/lib/orpc.ts +++ b/apps/web/src/lib/orpc.ts @@ -1,4 +1,4 @@ -import type { InternalRouter } from "@/server/orpc/internal/router"; +import type { AppRouter } from "@/server/orpc/router"; import type { InferRouterInputs, InferRouterOutputs, @@ -10,8 +10,23 @@ import { RPCLink } from "@orpc/client/fetch"; import { BatchLinkPlugin } from "@orpc/client/plugins"; import { createTanstackQueryUtils } from "@orpc/tanstack-query"; -const link = new RPCLink({ +/** + * Client context for per-call auth scoping. Shared (REST-shaped) procedures + * resolve the organization from this header on the session branch — pass it + * wherever a call has no org-bearing input: + * + * @example orpc.apiKey.create.mutationOptions({ context: { orgId: organization.id } }) + */ +export interface ORPCClientContext { + orgId?: string; +} + +const link = new RPCLink({ url: () => getBaseUrl() + "/api/rpc", + // per-call headers survive batching: the batch body carries per-item headers + // and the server's RequestHeadersPlugin restores them per procedure call + headers: ({ context }) => + context.orgId ? { "x-organization-id": context.orgId } : {}, plugins: [ new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }], @@ -20,7 +35,8 @@ const link = new RPCLink({ }); /** Imperative client — the `trpcClient.x.y.query()` replacement. */ -export const client: RouterClient = createORPCClient(link); +export const client: RouterClient = + createORPCClient(link); /** TanStack Query utils — the `useTRPC()` replacement (plain import, no hook). */ export const orpc = createTanstackQueryUtils(client); @@ -30,5 +46,5 @@ export const orpc = createTanstackQueryUtils(client); * * @example type Namespaces = RouterOutputs["namespace"]["getOrgNamespaces"] */ -export type RouterInputs = InferRouterInputs; -export type RouterOutputs = InferRouterOutputs; +export type RouterInputs = InferRouterInputs; +export type RouterOutputs = InferRouterOutputs; diff --git a/apps/web/src/server/orpc/base.ts b/apps/web/src/server/orpc/base.ts index b10fc4ce..67ab429b 100644 --- a/apps/web/src/server/orpc/base.ts +++ b/apps/web/src/server/orpc/base.ts @@ -8,29 +8,51 @@ import { } from "@/lib/api/errors"; import { getNamespace } from "@/lib/api/handler/namespace"; import { ratelimit } from "@/lib/api/rate-limit"; +import { auth } from "@/lib/auth"; import { tenantHeaderSchema } from "@/schemas/api/params"; import { ORPCError, os, ValidationError } from "@orpc/server"; import { z, ZodError } from "zod/v4"; import type { Organization } from "@agentset/db"; +import { db } from "@agentset/db/client"; import { normalizeId, tryCatch } from "@agentset/utils"; /** * --------------------------------------------------------------------------- - * Public API surface (api.agentset.ai/v1) — org API-key auth + * One router, two credentials. + * + * Every procedure in the app router runs on the same initial context. The + * `api` builder authenticates conditionally: an `Authorization` header means + * org API-key auth (public REST / MCP semantics: rate limit, tenant header, + * analytics identity), no header means a better-auth session (dashboard) + * scoped to the organization named by the `x-organization-id` header. + * Dashboard-only procedures use `protectedProcedure` (session required, no + * org resolution) and never carry OpenAPI route metadata, which keeps them + * off the public REST surface, the generated spec, and the MCP toolset. * --------------------------------------------------------------------------- */ export type PublicOrganization = Pick & ApiKeyInfo["organization"]; +export type MemberRole = "owner" | "admin" | "member"; + +export type ApiAuth = + | { type: "apiKey"; scope: string } + | { type: "session"; session: Session; memberRole: MemberRole }; + /** * Mutable bags on the initial context: middlewares write into them in place so * the route handler can read rate-limit headers / analytics identity after * `handler.handle()` returns (success or error). */ -export interface PublicApiContext { - headers: Headers; +export interface ApiContext { + /** + * Per-request headers. Filled by `RequestHeadersPlugin` on both route + * handlers so batched dashboard calls see their own per-item headers; the + * MCP bridge passes them explicitly. + */ + reqHeaders?: Headers; resHeaders: Record; analytics: { organization?: PublicOrganization; @@ -38,9 +60,26 @@ export interface PublicApiContext { namespaceId?: string; routeName?: string; }; + /** + * Session pre-resolved once per HTTP request by the dashboard rpc route + * (shared across batched procedure calls). `undefined` means "not resolved + * yet" — the auth middleware falls back to `auth.api.getSession`. + */ + session?: Session | null; } -const publicBase = os.$context(); +export const ORG_HEADER = "x-organization-id"; + +const base = os.$context(); + +const headersOf = (context: ApiContext) => context.reqHeaders ?? new Headers(); + +export const resolveSession = async ( + context: ApiContext, +): Promise => + context.session !== undefined + ? context.session + : auth.api.getSession({ headers: headersOf(context) }); const getTenantFromHeaders = (headers: Headers) => { const tenantId = headers.get("x-tenant-id") ?? undefined; @@ -56,16 +95,38 @@ const getTenantFromHeaders = (headers: Headers) => { return parsedTenantId.data; }; +/** Field parity with `ApiKeyInfo["organization"]` (lib/api/api-key.ts). */ +const organizationSelect = { + id: true, + name: true, + plan: true, + apiRatelimit: true, + searchLimit: true, + searchUsage: true, + totalPages: true, + pagesLimit: true, +} as const; + /** - * Port of `withApiHandler` (lib/api/handler/base.ts): loose Bearer parsing, - * cached key lookup, per-org rate limit (headers attached to every response), - * x-tenant-id validation. Error messages must stay byte-identical. + * The conditional auth middleware. + * + * API-key branch: byte-parity port of the legacy `withApiHandler` + * (lib/api/handler/base.ts): loose Bearer parsing, cached key lookup, per-org + * rate limit (headers attached to every response), x-tenant-id validation. + * Error messages must stay byte-identical — including the keyless 401, which + * the session branch reuses when no session exists either. + * + * Session branch: better-auth session + `x-organization-id` header (raw or + * org_-prefixed id), membership-checked and hydrated to the same + * `PublicOrganization` shape the key lookup provides. No rate limit (parity + * with the old dashboard router) and no analytics identity (the v1 route only + * logs API-key traffic). */ -const apiKeyAuthMiddleware = publicBase.middleware( +const conditionalAuthMiddleware = base.middleware( async ({ context, next, procedure }) => { - let apiKey: string | undefined = undefined; + const headers = headersOf(context); + const authorizationHeader = headers.get("Authorization"); - const authorizationHeader = context.headers.get("Authorization"); if (authorizationHeader) { if (!authorizationHeader.includes("Bearer ")) { throw new AgentsetApiError({ @@ -74,77 +135,162 @@ const apiKeyAuthMiddleware = publicBase.middleware( "Misconfigured authorization header. Did you forget to add 'Bearer '?", }); } - apiKey = authorizationHeader.replace("Bearer ", ""); + const apiKey = authorizationHeader.replace("Bearer ", ""); + + if (!apiKey) { + throw new AgentsetApiError({ + code: "unauthorized", + message: "Unauthorized: Invalid API key.", + }); + } + + const orgApiKey = await tryCatch(getApiKeyInfo(apiKey)); + if (!orgApiKey.data) { + throw new AgentsetApiError({ + code: "unauthorized", + message: "Unauthorized: Invalid API key.", + }); + } + + const rateLimit = orgApiKey.data.organization.apiRatelimit; + const { success, limit, reset, remaining } = await ratelimit( + rateLimit, + "1 m", + ).limit(orgApiKey.data.organizationId); + + Object.assign(context.resHeaders, { + "Retry-After": reset.toString(), + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }); + + if (!success) { + throw new AgentsetApiError({ + code: "rate_limit_exceeded", + message: "Too many requests.", + }); + } + + const tenantId = getTenantFromHeaders(headers); + const organization: PublicOrganization = { + id: orgApiKey.data.organizationId, + ...orgApiKey.data.organization, + }; + + const { method, path } = procedure["~orpc"].route; + context.analytics.organization = organization; + context.analytics.tenantId = tenantId; + // keep the legacy `GET /v1/organization` / `POST /v1/namespace/[namespaceId]/chat` labels + context.analytics.routeName = path + ? `${method ?? "POST"} /v1${path.replace(/\{(\w+)\}/g, "[$1]")}` + : undefined; + + return next({ + context: { + auth: { + type: "apiKey", + scope: orgApiKey.data.scope, + } satisfies ApiAuth as ApiAuth, + organization, + apiScope: orgApiKey.data.scope, + tenantId, + }, + }); } - if (!apiKey) { + const session = await resolveSession(context); + if (!session) { throw new AgentsetApiError({ code: "unauthorized", message: "Unauthorized: Invalid API key.", }); } - const orgApiKey = await tryCatch(getApiKeyInfo(apiKey)); - if (!orgApiKey.data) { + const orgHeader = headers.get(ORG_HEADER); + if (!orgHeader) { throw new AgentsetApiError({ - code: "unauthorized", - message: "Unauthorized: Invalid API key.", + code: "bad_request", + message: `Missing ${ORG_HEADER} header.`, }); } - const rateLimit = orgApiKey.data.organization.apiRatelimit; - const { success, limit, reset, remaining } = await ratelimit( - rateLimit, - "1 m", - ).limit(orgApiKey.data.organizationId); - - Object.assign(context.resHeaders, { - "Retry-After": reset.toString(), - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": remaining.toString(), - "X-RateLimit-Reset": reset.toString(), + // the header carries the raw organization id — it's a dashboard-internal + // contract (the client always has the raw id from the session bootstrap), + // so no org_ prefix normalization happens here + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgHeader, + }, + select: { + role: true, + organization: { select: organizationSelect }, + }, }); - if (!success) { + if (!member) { throw new AgentsetApiError({ - code: "rate_limit_exceeded", - message: "Too many requests.", + code: "unauthorized", + message: "Unauthorized: You don't have access to this organization.", }); } - const tenantId = getTenantFromHeaders(context.headers); - const organization: PublicOrganization = { - id: orgApiKey.data.organizationId, - ...orgApiKey.data.organization, - }; - - const { method, path } = procedure["~orpc"].route; - context.analytics.organization = organization; - context.analytics.tenantId = tenantId; - // keep the legacy `GET /v1/organization` / `POST /v1/namespace/[namespaceId]/chat` labels - context.analytics.routeName = path - ? `${method ?? "POST"} /v1${path.replace(/\{(\w+)\}/g, "[$1]")}` - : undefined; + const tenantId = getTenantFromHeaders(headers); return next({ context: { - organization, - apiScope: orgApiKey.data.scope, + auth: { + type: "session", + session, + memberRole: member.role as MemberRole, + } satisfies ApiAuth as ApiAuth, + organization: member.organization, + apiScope: "all", tenantId, }, }); }, ); -/** Base builder for public v1 procedures (org resolved, rate limited). */ -export const publicApi = publicBase.use(apiKeyAuthMiddleware); +/** + * Base builder for the shared (REST + MCP + dashboard) procedures: org + * resolved from either credential, rate limited on the API-key path. + */ +export const api = base.use(conditionalAuthMiddleware); + +/** + * Role gate for shared mutations. API keys act with full org authority + * (parity with the public REST surface, where the key itself is the + * credential); session members must hold one of the given roles — parity + * with the old dashboard-only procedures. + */ +export const requireRoles = (...roles: MemberRole[]) => + os + .$context() + .middleware(({ context, next }) => { + if ( + context.auth.type === "session" && + !roles.includes(context.auth.memberRole) + ) { + throw new AgentsetApiError({ + code: "unauthorized", + message: + "Unauthorized: You don't have permission to perform this action.", + }); + } + + return next(); + }); /** * Port of `withNamespaceApiHandler`: apply per procedure AFTER `.input()` as - * `.use(requireNamespace, (input) => input.namespaceId)`. + * `.use(requireNamespace, (input) => input.namespaceId)`. Membership was + * already verified by the auth middleware, so the org-ownership check is the + * same for both credentials. */ export const requireNamespace = os - .$context() + .$context() .middleware(async ({ context, next }, namespaceIdInput: string) => { const namespaceId = normalizeId(namespaceIdInput, "ns_"); if (!namespaceId) { @@ -189,7 +335,7 @@ export const successSchema = ( }); /** - * Legacy error mapping. Anything thrown inside a public procedure funnels + * Legacy error mapping. Anything thrown inside a shared procedure funnels * through here (as a client interceptor on the OpenAPIHandler) and is * re-thrown as an ORPCError whose status matches `errorCodeToHttpStatus` and * whose `data` carries the exact legacy body for the response encoder. @@ -245,56 +391,78 @@ export const legacyErrorResponseBodyEncoder = ( }; /** - * --------------------------------------------------------------------------- - * Internal surface (dashboard RPC) — better-auth session - * --------------------------------------------------------------------------- + * Dashboard RPC surface counterpart of the legacy encoder: business errors + * become typed transport errors (4xx with a message instead of 500). Applied + * as a client interceptor on the RPCHandler. */ - -export interface InternalContext { - headers: Headers; - /** resolved once per HTTP request in the rpc route handler */ - session: Session | null; -} - -const internalBase = os.$context(); +export const toDashboardORPCError = (error: unknown): unknown => { + if (error instanceof AgentsetApiError) { + return new ORPCError(error.code.toUpperCase(), { + status: errorCodeToHttpStatus[error.code], + message: error.message, + }); + } + return error; +}; /** - * Port of the tRPC `timingMiddleware`: timing log + AgentsetApiError → typed - * transport error (dashboard mutations surface 4xx instead of 500). + * --------------------------------------------------------------------------- + * Dashboard-only procedures — better-auth session, no org header contract. + * These never carry `.route()` metadata: no REST path, no OpenAPI entry, no + * MCP tool. + * --------------------------------------------------------------------------- */ -const timingMiddleware = internalBase.middleware(async ({ next, path }) => { - const start = Date.now(); - try { - const result = await next(); +export const protectedProcedure = base.use(async ({ context, next }) => { + const session = await resolveSession(context); - const end = Date.now(); - console.log(`[RPC] ${path.join(".")} took ${end - start}ms to execute`); - - return result; - } catch (error) { - if (error instanceof AgentsetApiError) { - throw new ORPCError(error.code.toUpperCase(), { - status: errorCodeToHttpStatus[error.code], - message: error.message, - }); - } - throw error; - } -}); - -export const publicProcedure = internalBase.use(timingMiddleware); - -export const protectedProcedure = publicProcedure.use(({ context, next }) => { - if (!context.session) { + if (!session) { throw new ORPCError("UNAUTHORIZED"); } return next({ context: { - session: context.session, + session, }, }); }); -export type ProtectedContext = InternalContext & { session: Session }; +export type ProtectedContext = ApiContext & { session: Session }; + +/** + * Shared membership check for dashboard-only procedures (replaces the + * per-router `validateIsMember`/inline `db.member.findFirst` copies). Role + * failures and missing membership throw the same codes the old internal + * routers used; pass `onMissing: "NOT_FOUND"` where that was the legacy + * behavior (hosting, billing). + */ +export const requireMember = async ( + session: Session, + org: { id: string } | { slug: string }, + opts?: { roles?: MemberRole[]; onMissing?: "UNAUTHORIZED" | "NOT_FOUND" }, +) => { + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organization: "id" in org ? { id: org.id } : { slug: org.slug }, + }, + select: { + id: true, + role: true, + organizationId: true, + organization: { + select: { id: true, slug: true, plan: true, stripeId: true }, + }, + }, + }); + + if (!member) { + throw new ORPCError(opts?.onMissing ?? "UNAUTHORIZED"); + } + + if (opts?.roles && !opts.roles.includes(member.role as MemberRole)) { + throw new ORPCError("UNAUTHORIZED"); + } + + return member; +}; diff --git a/apps/web/src/server/orpc/internal/api-keys.ts b/apps/web/src/server/orpc/internal/api-keys.ts deleted file mode 100644 index ff598b4f..00000000 --- a/apps/web/src/server/orpc/internal/api-keys.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { protectedProcedure } from "@/server/orpc/base"; -import { createApiKey, createApiKeySchema } from "@/services/api-key/create"; -import { deleteApiKey } from "@/services/api-key/delete"; -import { listApiKeys } from "@/services/api-key/list"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { Prisma } from "@agentset/db"; -import { db } from "@agentset/db/client"; - -export const apiKeysRouter = { - getApiKeys: protectedProcedure - .input( - z.object({ - orgId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - // make sure the user is a member of the org - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.orgId, - }, - select: { - id: true, - role: true, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - const apiKeys = await listApiKeys({ organizationId: input.orgId }); - - return apiKeys; - }), - getDefaultApiKey: protectedProcedure - .input(z.object({ orgId: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.orgId, - }, - select: { id: true }, - }); - - if (!member) { - throw new ORPCError("UNAUTHORIZED"); - } - - const apiKey = await db.organizationApiKey.findFirst({ - where: { - organizationId: input.orgId, - label: "Default API Key", - }, - select: { key: true }, - }); - - return apiKey?.key ?? null; - }), - createApiKey: protectedProcedure - .input(createApiKeySchema) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - const apiKey = await createApiKey(input); - - return apiKey; - }), - deleteApiKey: protectedProcedure - .input(z.object({ orgId: z.string(), id: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.orgId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - try { - // scoped to the org so a member of one org can't delete another org's key - await deleteApiKey({ id: input.id, organizationId: input.orgId }); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2025" - ) { - throw new ORPCError("NOT_FOUND"); - } - - throw error; - } - - return { success: true }; - }), -}; diff --git a/apps/web/src/server/orpc/internal/documents.ts b/apps/web/src/server/orpc/internal/documents.ts deleted file mode 100644 index 9f4cbd38..00000000 --- a/apps/web/src/server/orpc/internal/documents.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { getDocumentsSchema } from "@/schemas/api/document"; -import { protectedProcedure } from "@/server/orpc/base"; -import { deleteDocument } from "@/services/documents/delete"; -import { getPaginationArgs, paginateResults } from "@/services/pagination"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { DocumentStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { presignChunksDownloadUrl, presignGetUrl } from "@agentset/storage"; - -import { getNamespaceByUser } from "./helpers"; - -export const documentsRouter = { - all: protectedProcedure - .input( - getDocumentsSchema.extend({ - namespaceId: z.string(), - ingestJobId: z.string().optional(), - }), - ) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - const { where, ...paginationArgs } = getPaginationArgs( - input, - { - orderBy: input.orderBy, - order: input.order, - }, - "doc_", - ); - - const documents = await db.document.findMany({ - where: { - namespaceId: namespace.id, - ...(input.ingestJobId && { ingestJobId: input.ingestJobId }), - ...(input.statuses && - input.statuses.length > 0 && { status: { in: input.statuses } }), - ...where, - }, - select: { - id: true, - name: true, - totalTokens: true, - totalChunks: true, - totalCharacters: true, - source: true, - totalPages: true, - documentProperties: true, - createdAt: true, - queuedAt: true, - completedAt: true, - failedAt: true, - error: true, - status: true, - }, - ...paginationArgs, - }); - - return paginateResults(input, documents); - }), - delete: protectedProcedure - .input( - z.object({ - documentId: z.string(), - namespaceId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - const document = await db.document.findUnique({ - where: { - id: input.documentId, - namespaceId: namespace.id, - }, - select: { id: true, status: true }, - }); - - if (!document) { - throw new ORPCError("NOT_FOUND"); - } - - if ( - document.status === DocumentStatus.QUEUED_FOR_DELETE || - document.status === DocumentStatus.DELETING - ) { - throw new ORPCError("BAD_REQUEST"); - } - - const updatedDocument = await deleteDocument({ - documentId: input.documentId, - organizationId: namespace.organizationId, - }); - - return updatedDocument; - }), - getChunksDownloadUrl: protectedProcedure - .input( - z.object({ - documentId: z.string(), - namespaceId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - const document = await db.document.findUnique({ - where: { - id: input.documentId, - namespaceId: namespace.id, - }, - select: { id: true, status: true }, - }); - - if (!document) { - throw new ORPCError("NOT_FOUND"); - } - - if (document.status !== DocumentStatus.COMPLETED) { - throw new ORPCError("BAD_REQUEST", { - message: "Chunks are only available for completed documents", - }); - } - - const url = await presignChunksDownloadUrl(namespace.id, document.id); - if (!url) { - throw new ORPCError("NOT_FOUND"); - } - - return { url }; - }), - getFileDownloadUrl: protectedProcedure - .input( - z.object({ - documentId: z.string(), - namespaceId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - const document = await db.document.findUnique({ - where: { - id: input.documentId, - namespaceId: namespace.id, - }, - select: { id: true, name: true, source: true }, - }); - - if (!document) { - throw new ORPCError("NOT_FOUND"); - } - - if (document.source.type !== "MANAGED_FILE") { - throw new ORPCError("BAD_REQUEST", { - message: "File download is only available for managed files", - }); - } - - const { url } = await presignGetUrl(document.source.key, { - fileName: document.name ?? undefined, - }); - - return { url }; - }), -}; diff --git a/apps/web/src/server/orpc/internal/domains.ts b/apps/web/src/server/orpc/internal/domains.ts deleted file mode 100644 index 14c92820..00000000 --- a/apps/web/src/server/orpc/internal/domains.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { ProtectedContext } from "@/server/orpc/base"; -import { protectedProcedure } from "@/server/orpc/base"; -import { addDomain } from "@/services/domains/add"; -import { checkDomainStatus } from "@/services/domains/check-status"; -import { removeDomain } from "@/services/domains/remove"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { db } from "@agentset/db/client"; - -const commonInput = z.object({ - namespaceId: z.string(), -}); - -export type { DomainVerificationStatusProps } from "@/schemas/api/hosting"; - -const getHosting = async ( - ctx: Pick, - input: z.infer, -) => { - const hosting = await db.hosting.findFirst({ - where: { - namespace: { - id: input.namespaceId, - organization: { - members: { some: { userId: ctx.session.user.id } }, - }, - }, - }, - }); - - return hosting ?? null; -}; - -const getHostingOrThrow = async ( - ctx: Pick, - input: z.infer, -) => { - const hosting = await getHosting(ctx, input); - if (!hosting) { - throw new ORPCError("NOT_FOUND", { - message: "Hosting not found", - }); - } - - return hosting; -}; - -export const domainsRouter = { - add: protectedProcedure - .input(commonInput.extend({ domain: z.string() })) - .handler(async ({ context, input }) => { - const hosting = await getHostingOrThrow(context, input); - return addDomain({ hostingId: hosting.id, domain: input.domain }); - }), - checkStatus: protectedProcedure - .input(commonInput) - .handler(async ({ context, input }) => { - const hosting = await getHostingOrThrow(context, input); - return checkDomainStatus({ hostingId: hosting.id }); - }), - remove: protectedProcedure - .input(commonInput) - .handler(async ({ context, input }) => { - const hosting = await getHostingOrThrow(context, input); - return removeDomain({ hostingId: hosting.id }); - }), -}; diff --git a/apps/web/src/server/orpc/internal/hosting.ts b/apps/web/src/server/orpc/internal/hosting.ts deleted file mode 100644 index 419e78c9..00000000 --- a/apps/web/src/server/orpc/internal/hosting.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ProtectedContext } from "@/server/orpc/base"; -import { updateHostingSchema } from "@/schemas/api/hosting"; -import { protectedProcedure } from "@/server/orpc/base"; -import { deleteHosting } from "@/services/hosting/delete"; -import { enableHosting } from "@/services/hosting/enable"; -import { getHosting } from "@/services/hosting/get"; -import { updateHosting } from "@/services/hosting/update"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { db } from "@agentset/db/client"; - -const commonInput = z.object({ namespaceId: z.string() }); - -const verifyNamespaceAccess = async ( - context: Pick, - namespaceId: string, -) => { - const namespace = await db.namespace.findFirst({ - where: { - id: namespaceId, - organization: { - members: { some: { userId: context.session.user.id } }, - }, - }, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND", { - message: "Namespace not found or you don't have access to it", - }); - } - - return namespace; -}; - -// TODO: only allow for pro users -export const hostingRouter = { - get: protectedProcedure - .input(commonInput) - .handler(async ({ context, input }) => { - await verifyNamespaceAccess(context, input.namespaceId); - return getHosting({ namespaceId: input.namespaceId }); - }), - enable: protectedProcedure - .input(commonInput) - .handler(async ({ context, input }) => { - await verifyNamespaceAccess(context, input.namespaceId); - - return enableHosting({ - namespaceId: input.namespaceId, - }); - }), - update: protectedProcedure - .input(commonInput.extend(updateHostingSchema.shape)) - .handler(async ({ context, input: { namespaceId, ...input } }) => { - await verifyNamespaceAccess(context, namespaceId); - - return updateHosting({ - namespaceId, - input, - }); - }), - delete: protectedProcedure - .input(commonInput) - .handler(async ({ context, input }) => { - await verifyNamespaceAccess(context, input.namespaceId); - - await deleteHosting({ namespaceId: input.namespaceId }); - - return { success: true }; - }), -}; diff --git a/apps/web/src/server/orpc/internal/ingest-jobs.ts b/apps/web/src/server/orpc/internal/ingest-jobs.ts deleted file mode 100644 index cd796b68..00000000 --- a/apps/web/src/server/orpc/internal/ingest-jobs.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - createIngestJobSchema, - getIngestionJobsSchema, -} from "@/schemas/api/ingest-job"; -import { protectedProcedure } from "@/server/orpc/base"; -import { createIngestJob } from "@/services/ingest-jobs/create"; -import { deleteIngestJob } from "@/services/ingest-jobs/delete"; -import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; -import { getPaginationArgs, paginateResults } from "@/services/pagination"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { db } from "@agentset/db/client"; - -import { getNamespaceByUser } from "./helpers"; - -export const ingestJobsRouter = { - all: protectedProcedure - .input(getIngestionJobsSchema.extend({ namespaceId: z.string() })) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - const { where, ...paginationArgs } = getPaginationArgs( - input, - { - orderBy: input.orderBy, - order: input.order, - }, - "job_", - ); - - const ingestJobs = await db.ingestJob.findMany({ - where: { - namespaceId: input.namespaceId, - ...(input.statuses && - input.statuses.length > 0 && { - status: { in: input.statuses }, - }), - ...where, - }, - select: { - id: true, - status: true, - name: true, - tenantId: true, - completedAt: true, - failedAt: true, - error: true, - queuedAt: true, - createdAt: true, - }, - ...paginationArgs, - }); - - return paginateResults(input, ingestJobs); - }), - getConfig: protectedProcedure - .input(z.object({ jobId: z.string(), namespaceId: z.string() })) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - const ingestJob = await db.ingestJob.findUnique({ - where: { - id: input.jobId, - namespaceId: namespace.id, - }, - select: { - config: true, - }, - }); - - if (!ingestJob) { - throw new ORPCError("NOT_FOUND"); - } - - return ingestJob.config; - }), - ingest: protectedProcedure - .input( - createIngestJobSchema.extend({ - namespaceId: z.string(), - }), - ) - .handler(async ({ context, input: { namespaceId, ...input } }) => { - const namespace = await getNamespaceByUser(context, { - id: namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - const organization = await db.organization.findUnique({ - where: { id: namespace.organizationId }, - }); - - if (!organization) { - throw new ORPCError("NOT_FOUND"); - } - - return await createIngestJob({ - data: input, - organization, - namespaceId: namespace.id, - }); - }), - delete: protectedProcedure - .input(z.object({ jobId: z.string(), namespaceId: z.string() })) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - return await deleteIngestJob({ - jobId: input.jobId, - namespaceId: namespace.id, - organizationId: namespace.organizationId, - }); - }), - reIngest: protectedProcedure - .input(z.object({ jobId: z.string(), namespaceId: z.string() })) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - // the service fetches the org plan in parallel with the job lookup - return await reIngestJob({ - jobId: input.jobId, - namespaceId: namespace.id, - organizationId: namespace.organizationId, - }); - }), -}; diff --git a/apps/web/src/server/orpc/internal/namespaces.ts b/apps/web/src/server/orpc/internal/namespaces.ts deleted file mode 100644 index 6bdd7729..00000000 --- a/apps/web/src/server/orpc/internal/namespaces.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { ProtectedContext } from "@/server/orpc/base"; -import { createNamespaceSchema } from "@/schemas/api/namespace"; -import { protectedProcedure } from "@/server/orpc/base"; -import { createNamespace } from "@/services/namespaces/create"; -import { deleteNamespace } from "@/services/namespaces/delete"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { NamespaceStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { getDemoTemplate } from "@agentset/demo"; -import { triggerSeedDemoNamespace } from "@agentset/jobs"; - -const validateIsMember = async ( - ctx: Pick, - orgId: string | { slug: string }, - roles?: string[], -) => { - const member = await db.member.findFirst({ - where: { - userId: ctx.session.user.id, - ...(typeof orgId === "string" - ? { organizationId: orgId } - : { - organization: { - slug: orgId.slug, - }, - }), - }, - select: { - id: true, - role: true, - organizationId: true, - }, - }); - - if (!member) { - throw new ORPCError("UNAUTHORIZED"); - } - - if (roles && !roles.includes(member.role)) { - throw new ORPCError("UNAUTHORIZED"); - } - - return member; -}; - -export const namespacesRouter = { - getOrgNamespaces: protectedProcedure - .input( - z.object({ - slug: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const member = await validateIsMember(context, { slug: input.slug }); - - const namespaces = await db.namespace.findMany({ - where: { - organizationId: member.organizationId, - status: NamespaceStatus.ACTIVE, - }, - orderBy: { - createdAt: "desc", - }, - }); - - return namespaces; - }), - getNamespaceBySlug: protectedProcedure - .input(z.object({ orgSlug: z.string(), slug: z.string() })) - .handler(async ({ context, input }) => { - const namespace = await db.namespace.findFirst({ - where: { - slug: input.slug, - organization: { - slug: input.orgSlug, - members: { some: { userId: context.session.user.id } }, - }, - status: NamespaceStatus.ACTIVE, - }, - }); - - return namespace; - }), - getOnboardingStatus: protectedProcedure - .input(z.object({ orgSlug: z.string(), slug: z.string() })) - .handler(async ({ context, input }) => { - const namespace = await db.namespace.findFirst({ - where: { - slug: input.slug, - organization: { - slug: input.orgSlug, - members: { some: { userId: context.session.user.id } }, - }, - status: NamespaceStatus.ACTIVE, - }, - select: { - totalIngestJobs: true, - totalPlaygroundUsage: true, - organization: { - select: { - apiKeys: { - take: 1, - select: { - id: true, - }, - }, - }, - }, - }, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - return { - ingestDocuments: namespace.totalIngestJobs > 0, - playground: namespace.totalPlaygroundUsage > 0, - createApiKey: namespace.organization.apiKeys.length > 0, - }; - }), - checkSlug: protectedProcedure - .input( - z.object({ - orgId: z.string(), - slug: z.string(), - }), - ) - .handler(async ({ input }) => { - const namespace = await db.namespace.findUnique({ - where: { - organizationId_slug: { - slug: input.slug, - organizationId: input.orgId, - }, - }, - }); - - return !!namespace; - }), - createNamespace: protectedProcedure - .input( - createNamespaceSchema.extend( - z.object({ - orgId: z.string(), - }).shape, - ), - ) - .handler(async ({ context, input: { orgId, ...data } }) => { - await validateIsMember(context, orgId, ["admin", "owner"]); - - return await createNamespace({ - organizationId: orgId, - data, - }); - }), - createDemoNamespace: protectedProcedure - .input( - z.object({ - orgId: z.string(), - templateId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - await validateIsMember(context, input.orgId, ["admin", "owner"]); - - const template = getDemoTemplate(input.templateId); - if (!template) { - throw new ORPCError("BAD_REQUEST", { - message: "Invalid template ID", - }); - } - - const organization = await db.organization.findUnique({ - where: { id: input.orgId }, - select: { id: true, plan: true }, - }); - if (!organization) { - throw new ORPCError("NOT_FOUND"); - } - - let slug: string = template.id; - let suffix = 2; - while (true) { - const existing = await db.namespace.findUnique({ - where: { - organizationId_slug: { - organizationId: input.orgId, - slug, - }, - }, - select: { id: true }, - }); - - if (!existing) break; - - slug = `${template.id}-${suffix}`; - suffix += 1; - } - - const [namespace] = await db.$transaction([ - db.namespace.create({ - data: { - name: template.name, - slug, - demoId: template.id, - organizationId: input.orgId, - embeddingConfig: { - provider: "MANAGED_OPENAI", - model: "text-embedding-3-large", - }, - vectorStoreConfig: { - provider: "MANAGED_TURBOPUFFER", - }, - }, - }), - db.organization.update({ - where: { id: input.orgId }, - data: { - totalNamespaces: { increment: 1 }, - }, - }), - ]); - - await triggerSeedDemoNamespace( - { - namespaceId: namespace.id, - organizationId: organization.id, - templateId: template.id, - }, - organization.plan, - ); - - return namespace; - }), - deleteNamespace: protectedProcedure - .input( - z.object({ - namespaceId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const namespace = await db.namespace.findFirst({ - where: { - id: input.namespaceId, - organization: { - members: { - some: { - userId: context.session.user.id, - role: { - in: ["admin", "owner"], - }, - }, - }, - }, - status: NamespaceStatus.ACTIVE, - }, - select: { - id: true, - organizationId: true, - }, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND", { - message: - "Namespace not found or you don't have permission to delete it", - }); - } - - await deleteNamespace({ namespaceId: namespace.id }); - - return { success: true }; - }), -}; diff --git a/apps/web/src/server/orpc/internal/organizations.ts b/apps/web/src/server/orpc/internal/organizations.ts deleted file mode 100644 index 4e80c7dc..00000000 --- a/apps/web/src/server/orpc/internal/organizations.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { protectedProcedure } from "@/server/orpc/base"; -import { deleteOrganization } from "@/services/organizations/delete"; -import { getOrganizationMembers } from "@/services/organizations/members"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { OrganizationStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; - -export const organizationsRouter = { - all: protectedProcedure.handler(async ({ context }) => { - const orgs = await db.organization.findMany({ - where: { - members: { - some: { - userId: context.session.user.id, - }, - }, - status: OrganizationStatus.ACTIVE, - }, - select: { - id: true, - name: true, - slug: true, - plan: true, - logo: true, - namespaces: { - select: { - id: true, - name: true, - slug: true, - }, - }, - }, - orderBy: { - createdAt: "asc", - }, - }); - - return orgs; - }), - getBySlug: protectedProcedure - .input(z.object({ slug: z.string() })) - .handler(async ({ context, input }) => { - const org = await db.organization.findUnique({ - where: { - slug: input.slug, - members: { - some: { - userId: context.session.user.id, - }, - }, - }, - include: { - members: { - where: { - userId: context.session.user.id, - }, - take: 1, - select: { - id: true, - role: true, - }, - }, - }, - }); - - if (!org) { - throw new ORPCError("NOT_FOUND"); - } - - const { members } = org; - const isAdmin = - members[0]?.role === "admin" || members[0]?.role === "owner"; - - return { - ...org, - isAdmin, - isOwner: members[0]?.role === "owner", - currentMemberId: members[0]?.id, - }; - }), - members: protectedProcedure - .input( - z.object({ - organizationId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const members = await getOrganizationMembers({ - organizationId: input.organizationId, - userId: context.session.user.id, - }); - - return members; - }), - delete: protectedProcedure - .input( - z.object({ - organizationId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const org = await db.organization.findUnique({ - where: { - id: input.organizationId, - members: { - some: { - userId: context.session.user.id, - role: { in: ["admin", "owner"] }, - }, - }, - }, - }); - - if (!org) { - throw new ORPCError("UNAUTHORIZED", { - message: "You are not authorized to delete this organization", - }); - } - - if (org.status === OrganizationStatus.DELETING) { - throw new ORPCError("BAD_REQUEST", { - message: "Organization is already being deleted", - }); - } - - await deleteOrganization({ organizationId: org.id }); - }), -}; diff --git a/apps/web/src/server/orpc/internal/router.ts b/apps/web/src/server/orpc/internal/router.ts deleted file mode 100644 index e0ca74d6..00000000 --- a/apps/web/src/server/orpc/internal/router.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { apiKeysRouter } from "./api-keys"; -import { billingRouter } from "./billing"; -import { documentsRouter } from "./documents"; -import { domainsRouter } from "./domains"; -import { hostingRouter } from "./hosting"; -import { ingestJobsRouter } from "./ingest-jobs"; -import { namespacesRouter } from "./namespaces"; -import { organizationsRouter } from "./organizations"; -import { searchRouter } from "./search"; -import { uploadsRouter } from "./uploads"; -import { webhooksRouter } from "./webhooks"; - -/** - * The dashboard RPC surface. Mount names intentionally match the old tRPC - * root router 1:1 so client-side query keys keep the same path segments. - */ -export const internalRouter = { - namespace: namespacesRouter, - apiKey: apiKeysRouter, - ingestJob: ingestJobsRouter, - document: documentsRouter, - upload: uploadsRouter, - billing: billingRouter, - organization: organizationsRouter, - hosting: hostingRouter, - domain: domainsRouter, - search: searchRouter, - webhook: webhooksRouter, -}; - -export type InternalRouter = typeof internalRouter; diff --git a/apps/web/src/server/orpc/internal/search.ts b/apps/web/src/server/orpc/internal/search.ts deleted file mode 100644 index 4a6f9b7c..00000000 --- a/apps/web/src/server/orpc/internal/search.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { incrementSearchUsage } from "@/lib/api/usage"; -import { protectedProcedure } from "@/server/orpc/base"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { - getNamespaceEmbeddingModel, - getNamespaceVectorStore, - queryVectorStore, -} from "@agentset/engine"; -import { rerankerSchema } from "@agentset/validation"; - -import { getNamespaceByUser } from "./helpers"; - -const chunkExplorerInputSchema = z.object({ - namespaceId: z.string(), - query: z.string().min(1), - topK: z.number().min(1).max(100), - rerank: z.boolean(), - rerankModel: rerankerSchema, - rerankLimit: z.number().min(1).max(100), - filter: z.record(z.string(), z.any()).optional(), -}); - -export const searchRouter = { - search: protectedProcedure - .input(chunkExplorerInputSchema) - .handler(async ({ context, input }) => { - const namespace = await getNamespaceByUser(context, { - id: input.namespaceId, - }); - - if (!namespace) { - throw new ORPCError("NOT_FOUND"); - } - - const [embeddingModel, vectorStore] = await Promise.all([ - getNamespaceEmbeddingModel(namespace, "query"), - getNamespaceVectorStore(namespace), - ]); - - const queryResult = await queryVectorStore({ - query: input.query, - topK: input.topK, - filter: input.filter, - includeMetadata: true, - rerank: input.rerank - ? { model: input.rerankModel, limit: input.rerankLimit } - : false, - embeddingModel, - vectorStore, - consistency: "strong", - }); - - // Track search usage - incrementSearchUsage(namespace.id, 1); - - return queryResult.results; - }), -}; diff --git a/apps/web/src/server/orpc/internal/uploads.ts b/apps/web/src/server/orpc/internal/uploads.ts deleted file mode 100644 index 0cd8ea8f..00000000 --- a/apps/web/src/server/orpc/internal/uploads.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { batchUploadSchema, uploadFileSchema } from "@/schemas/api/upload"; -import { protectedProcedure } from "@/server/orpc/base"; -import { createBatchUpload, createUpload } from "@/services/uploads"; -import { ORPCError } from "@orpc/server"; -import { z } from "zod/v4"; - -import { db } from "@agentset/db/client"; - -import { getNamespaceByUser } from "./helpers"; - -export const uploadsRouter = { - getPresignedUrl: protectedProcedure - .input( - uploadFileSchema.extend({ - namespaceId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const ns = await getNamespaceByUser(context, { id: input.namespaceId }); - - if (!ns) { - throw new ORPCError("NOT_FOUND", { - message: "Namespace not found", - }); - } - - const organization = await db.organization.findUnique({ - where: { id: ns.organizationId }, - select: { plan: true }, - }); - - if (!organization) { - throw new ORPCError("NOT_FOUND", { - message: "Organization not found", - }); - } - - const result = await createUpload({ - namespaceId: ns.id, - plan: organization.plan, - file: { - fileName: input.fileName, - contentType: input.contentType, - fileSize: input.fileSize, - }, - }); - - if (!result.success) { - throw new ORPCError( - result.code === "file_too_large" - ? "FORBIDDEN" - : "INTERNAL_SERVER_ERROR", - { - message: result.error, - }, - ); - } - - return result.data; - }), - getPresignedUrls: protectedProcedure - .input( - batchUploadSchema.extend({ - namespaceId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const ns = await getNamespaceByUser(context, { id: input.namespaceId }); - - if (!ns) { - throw new ORPCError("NOT_FOUND", { - message: "Namespace not found", - }); - } - - const organization = await db.organization.findUnique({ - where: { id: ns.organizationId }, - select: { plan: true }, - }); - - if (!organization) { - throw new ORPCError("NOT_FOUND", { - message: "Organization not found", - }); - } - - const result = await createBatchUpload({ - namespaceId: ns.id, - plan: organization.plan, - files: input.files, - }); - - if (!result.success) { - throw new ORPCError( - result.code === "file_too_large" - ? "FORBIDDEN" - : "INTERNAL_SERVER_ERROR", - { - message: result.error, - }, - ); - } - - return result.data; - }), -}; diff --git a/apps/web/src/server/orpc/internal/webhooks.ts b/apps/web/src/server/orpc/internal/webhooks.ts deleted file mode 100644 index 95056c7d..00000000 --- a/apps/web/src/server/orpc/internal/webhooks.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { samplePayload } from "@/lib/webhook/sample-events/payload"; -import { protectedProcedure } from "@/server/orpc/base"; -import { createWebhook } from "@/services/webhooks/create"; -import { deleteWebhook } from "@/services/webhooks/delete"; -import { getWebhook } from "@/services/webhooks/get"; -import { listWebhooks } from "@/services/webhooks/list"; -import { requireWebhooksPlan } from "@/services/webhooks/plan"; -import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; -import { toggleWebhook } from "@/services/webhooks/toggle"; -import { updateWebhook } from "@/services/webhooks/update"; -import { ORPCError } from "@orpc/server"; -import { nanoid } from "nanoid"; -import { z } from "zod/v4"; - -import { db } from "@agentset/db/client"; -import { triggerSendWebhook } from "@agentset/jobs"; -import { getWebhookEvents } from "@agentset/tinybird"; -import { - createWebhookSchema, - updateWebhookSchema, - WEBHOOK_EVENT_ID_PREFIX, - WEBHOOK_TRIGGERS, - webhookPayloadSchema, -} from "@agentset/webhooks"; - -export const webhooksRouter = { - // List all webhooks for an organization - list: protectedProcedure - .input(z.object({ organizationId: z.string() })) - .handler(async ({ context, input }) => { - // Verify user is member of org - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member) { - throw new ORPCError("UNAUTHORIZED"); - } - - return listWebhooks({ organizationId: input.organizationId }); - }), - - // Get a single webhook by ID - get: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member) { - throw new ORPCError("UNAUTHORIZED"); - } - - const webhook = await getWebhook({ - organizationId: input.organizationId, - webhookId: input.webhookId, - }); - - if (!webhook) { - throw new ORPCError("NOT_FOUND"); - } - - return webhook; - }), - - // Get webhook events from Tinybird - getEvents: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member) { - throw new ORPCError("UNAUTHORIZED"); - } - - // Verify webhook belongs to org - const webhook = await db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - select: { id: true }, - }); - - if (!webhook) { - throw new ORPCError("NOT_FOUND"); - } - - const events = await getWebhookEvents({ webhookId: input.webhookId }); - return events.data; - }), - - // Create a new webhook - create: protectedProcedure - .input(createWebhookSchema.extend({ organizationId: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - include: { - organization: { - select: { plan: true }, - }, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - // Check pro plan requirement - requireWebhooksPlan(member.organization.plan); - - return createWebhook({ - name: input.name, - url: input.url, - secret: input.secret, - triggers: input.triggers, - namespaceIds: input.namespaceIds, - organizationId: input.organizationId, - }); - }), - - // Update a webhook - update: protectedProcedure - .input( - updateWebhookSchema.extend({ - organizationId: z.string(), - webhookId: z.string(), - }), - ) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - include: { - organization: { - select: { plan: true }, - }, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - requireWebhooksPlan(member.organization.plan); - - const webhook = await updateWebhook({ - organizationId: input.organizationId, - webhookId: input.webhookId, - name: input.name, - url: input.url, - secret: input.secret, - triggers: input.triggers, - namespaceIds: input.namespaceIds, - }); - - if (!webhook) { - throw new ORPCError("NOT_FOUND"); - } - - return webhook; - }), - - // Delete a webhook - delete: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - const webhook = await deleteWebhook({ - organizationId: input.organizationId, - webhookId: input.webhookId, - }); - - if (!webhook) { - throw new ORPCError("NOT_FOUND"); - } - - return { success: true }; - }), - - // Toggle webhook enabled/disabled - toggle: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - const updatedWebhook = await toggleWebhook({ - organizationId: input.organizationId, - webhookId: input.webhookId, - }); - - if (!updatedWebhook) { - throw new ORPCError("NOT_FOUND"); - } - - return { disabledAt: updatedWebhook.disabledAt }; - }), - - // Send test webhook event - sendTest: protectedProcedure - .input( - z.object({ - organizationId: z.string(), - webhookId: z.string(), - trigger: z.enum(WEBHOOK_TRIGGERS), - }), - ) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - const webhook = await db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - select: { - id: true, - url: true, - secret: true, - }, - }); - - if (!webhook) { - throw new ORPCError("NOT_FOUND"); - } - - const payload = webhookPayloadSchema.parse({ - id: `${WEBHOOK_EVENT_ID_PREFIX}${nanoid(25)}`, - event: input.trigger, - createdAt: new Date().toISOString(), - data: samplePayload[input.trigger], - }); - - await triggerSendWebhook({ - webhookId: webhook.id, - eventId: payload.id, - event: input.trigger, - url: webhook.url, - secret: webhook.secret, - payload, - }); - - return { success: true }; - }), - - // Generate a new secret for a webhook - regenerateSecret: protectedProcedure - .input(z.object({ organizationId: z.string(), webhookId: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member || (member.role !== "admin" && member.role !== "owner")) { - throw new ORPCError("UNAUTHORIZED"); - } - - const updatedWebhook = await regenerateWebhookSecret({ - organizationId: input.organizationId, - webhookId: input.webhookId, - }); - - if (!updatedWebhook) { - throw new ORPCError("NOT_FOUND"); - } - - return { secret: updatedWebhook.secret }; - }), - - // Get namespaces for webhook namespace selector - getNamespaces: protectedProcedure - .input(z.object({ organizationId: z.string() })) - .handler(async ({ context, input }) => { - const member = await db.member.findFirst({ - where: { - userId: context.session.user.id, - organizationId: input.organizationId, - }, - }); - - if (!member) { - throw new ORPCError("UNAUTHORIZED"); - } - - const namespaces = await db.namespace.findMany({ - where: { - organizationId: input.organizationId, - status: "ACTIVE", - }, - select: { - id: true, - name: true, - slug: true, - }, - orderBy: { - name: "asc", - }, - }); - - return namespaces; - }), -}; diff --git a/apps/web/src/server/orpc/public/router.ts b/apps/web/src/server/orpc/public/router.ts deleted file mode 100644 index 6b520994..00000000 --- a/apps/web/src/server/orpc/public/router.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { apiKeysRouter } from "./api-keys"; -import { chatRouter } from "./chat"; -import { documentsRouter } from "./documents"; -import { hostingRouter } from "./hosting"; -import { ingestJobsRouter } from "./ingest-jobs"; -import { namespacesRouter } from "./namespaces"; -import { organizationRouter } from "./organization"; -import { searchRouter } from "./search"; -import { uploadsRouter } from "./uploads"; -import { warmUpRouter } from "./warm-up"; -import { webhooksRouter } from "./webhooks"; - -/** - * The public v1 REST surface (api.agentset.ai/v1). Routing is driven entirely - * by each procedure's `.route({ method, path })` — the nesting here is - * organizational only. - */ -export const publicRouter = { - organization: organizationRouter, - apiKeys: apiKeysRouter, - namespaces: namespacesRouter, - documents: documentsRouter, - ingestJobs: ingestJobsRouter, - uploads: uploadsRouter, - search: searchRouter, - chat: chatRouter, - warmUp: warmUpRouter, - hosting: hostingRouter, - webhooks: webhooksRouter, -}; - -export type PublicRouter = typeof publicRouter; diff --git a/apps/web/src/server/orpc/public/api-keys.ts b/apps/web/src/server/orpc/router/api-keys.ts similarity index 71% rename from apps/web/src/server/orpc/public/api-keys.ts rename to apps/web/src/server/orpc/router/api-keys.ts index 7adb8d01..3deb1658 100644 --- a/apps/web/src/server/orpc/public/api-keys.ts +++ b/apps/web/src/server/orpc/router/api-keys.ts @@ -4,12 +4,19 @@ import { createApiKeyBodySchema, CreatedApiKeySchema, } from "@/schemas/api/api-key"; -import { publicApi, successSchema } from "@/server/orpc/base"; +import { + api, + protectedProcedure, + requireMember, + requireRoles, + successSchema, +} from "@/server/orpc/base"; import { createApiKey } from "@/services/api-key/create"; import { deleteApiKey } from "@/services/api-key/delete"; import { listApiKeys } from "@/services/api-key/list"; import { z } from "zod/v4"; +import { db } from "@agentset/db/client"; import { prefixId } from "@agentset/utils"; import { makeCodeSamples, ts } from "./code-samples"; @@ -24,7 +31,8 @@ const keyIdPathSchema = z.string().meta({ }, }); -const list = publicApi +const list = api + .use(requireRoles("admin", "owner")) .route({ method: "GET", path: "/api-keys", @@ -61,7 +69,8 @@ console.log(apiKeys); }; }); -const create = publicApi +const create = api + .use(requireRoles("admin", "owner")) .route({ method: "POST", path: "/api-keys", @@ -105,7 +114,8 @@ console.log(apiKey.key); }; }); -const remove = publicApi +const remove = api + .use(requireRoles("admin", "owner")) .route({ method: "DELETE", path: "/api-keys/{keyId}", @@ -148,4 +158,43 @@ export const apiKeysRouter = { list, create, delete: remove, + + // ------------------------------------------------------------------------- + // Dashboard-only (session) procedures — no .route(): never exposed on + // REST/OpenAPI/MCP. Raw outputs, raw ids. + // ------------------------------------------------------------------------- + + getApiKeys: protectedProcedure + .input( + z.object({ + orgId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + // make sure the user is a member of the org + await requireMember( + context.session, + { id: input.orgId }, + { roles: ["admin", "owner"] }, + ); + + const apiKeys = await listApiKeys({ organizationId: input.orgId }); + + return apiKeys; + }), + getDefaultApiKey: protectedProcedure + .input(z.object({ orgId: z.string() })) + .handler(async ({ context, input }) => { + await requireMember(context.session, { id: input.orgId }); + + const apiKey = await db.organizationApiKey.findFirst({ + where: { + organizationId: input.orgId, + label: "Default API Key", + }, + select: { key: true }, + }); + + return apiKey?.key ?? null; + }), }; diff --git a/apps/web/src/server/orpc/internal/billing.ts b/apps/web/src/server/orpc/router/billing.ts similarity index 94% rename from apps/web/src/server/orpc/internal/billing.ts rename to apps/web/src/server/orpc/router/billing.ts index b79c5c07..f85f65f1 100644 --- a/apps/web/src/server/orpc/internal/billing.ts +++ b/apps/web/src/server/orpc/router/billing.ts @@ -54,27 +54,6 @@ const orgInputSchema = z.object({ }); export const billingRouter = { - getCurrentPlan: protectedProcedure - .input(orgInputSchema) - .use(requireOrganization, (input) => input.orgId) - .handler(async ({ context }) => { - const org = await db.organization.findUnique({ - where: { - id: context.organization.id, - }, - select: { - plan: true, - pagesLimit: true, - totalPages: true, - totalDocuments: true, - totalIngestJobs: true, - billingCycleStart: true, - stripeId: true, - }, - }); - - return org!; - }), upgrade: protectedProcedure .input( orgInputSchema.extend({ diff --git a/apps/web/src/server/orpc/public/chat.ts b/apps/web/src/server/orpc/router/chat.ts similarity index 97% rename from apps/web/src/server/orpc/public/chat.ts rename to apps/web/src/server/orpc/router/chat.ts index d10b0166..8b58911b 100644 --- a/apps/web/src/server/orpc/public/chat.ts +++ b/apps/web/src/server/orpc/router/chat.ts @@ -5,7 +5,7 @@ import { import { generateChat, streamChat } from "@/lib/chat"; import { chatResponseSchema, chatSchema } from "@/schemas/api/chat"; import { namespaceIdPathSchema } from "@/schemas/api/params"; -import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { api, requireNamespace, successSchema } from "@/server/orpc/base"; import { toModelMessages } from "@/services/chat"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { z } from "zod/v4"; @@ -28,7 +28,7 @@ const chatSuccessJsonSchema = () => { return jsonSchema; }; -const execute = publicApi +const execute = api .route({ method: "POST", path: "/namespace/{namespaceId}/chat", diff --git a/apps/web/src/server/orpc/public/code-samples.ts b/apps/web/src/server/orpc/router/code-samples.ts similarity index 100% rename from apps/web/src/server/orpc/public/code-samples.ts rename to apps/web/src/server/orpc/router/code-samples.ts diff --git a/apps/web/src/server/orpc/public/documents.ts b/apps/web/src/server/orpc/router/documents.ts similarity index 83% rename from apps/web/src/server/orpc/public/documents.ts rename to apps/web/src/server/orpc/router/documents.ts index 5e653038..800e932c 100644 --- a/apps/web/src/server/orpc/public/documents.ts +++ b/apps/web/src/server/orpc/router/documents.ts @@ -3,7 +3,12 @@ import { namespaceIdPathSchema, } from "@/schemas/api/params"; import { DocumentSchema, getDocumentsSchema } from "@/schemas/api/document"; -import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { + api, + protectedProcedure, + requireNamespace, + successSchema, +} from "@/server/orpc/base"; import { queueDocumentDeletion } from "@/services/documents/delete"; import { getDocumentChunksDownloadUrl, @@ -11,15 +16,16 @@ import { } from "@/services/documents/download"; import { getDocumentOrThrow } from "@/services/documents/get"; import { getPaginationArgs, paginateResults } from "@/services/pagination"; -import { type } from "@orpc/server"; +import { ORPCError, type } from "@orpc/server"; import { z } from "zod/v4"; import { db } from "@agentset/db/client"; import { normalizeId, prefixId } from "@agentset/utils"; import { makeCodeSamples, ts } from "./code-samples"; +import { getNamespaceByUser } from "./helpers"; -const list = publicApi +const list = api .route({ method: "GET", path: "/namespace/{namespaceId}/documents", @@ -95,7 +101,7 @@ console.log(docs); }; }); -const get = publicApi +const get = api .route({ method: "GET", path: "/namespace/{namespaceId}/documents/{documentId}", @@ -138,7 +144,7 @@ console.log(document); }; }); -const del = publicApi +const del = api .route({ method: "DELETE", path: "/namespace/{namespaceId}/documents/{documentId}", @@ -184,7 +190,7 @@ console.log("Document queued for deletion"); }; }); -const getFileDownloadUrl = publicApi +const getFileDownloadUrl = api .route({ method: "POST", path: "/namespace/{namespaceId}/documents/{documentId}/file-download-url", @@ -248,7 +254,7 @@ fs.writeFileSync("file.pdf", Buffer.from(await file.arrayBuffer())); return { success: true as const, data }; }); -const getChunksDownloadUrl = publicApi +const getChunksDownloadUrl = api .route({ method: "POST", path: "/namespace/{namespaceId}/documents/{documentId}/chunks-download-url", @@ -312,7 +318,67 @@ console.log(data); return { success: true as const, data }; }); +/** + * Dashboard-only slim list ({ records, pagination }, raw un-prefixed ids, + * field subset). No `.route()` — stays off REST/OpenAPI/MCP. + */ +const all = protectedProcedure + .input( + getDocumentsSchema.extend({ + namespaceId: z.string(), + ingestJobId: z.string().optional(), + }), + ) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const { where, ...paginationArgs } = getPaginationArgs( + input, + { + orderBy: input.orderBy, + order: input.order, + }, + "doc_", + ); + + const documents = await db.document.findMany({ + where: { + namespaceId: namespace.id, + ...(input.ingestJobId && { ingestJobId: input.ingestJobId }), + ...(input.statuses && + input.statuses.length > 0 && { status: { in: input.statuses } }), + ...where, + }, + select: { + id: true, + name: true, + totalTokens: true, + totalChunks: true, + totalCharacters: true, + source: true, + totalPages: true, + documentProperties: true, + createdAt: true, + queuedAt: true, + completedAt: true, + failedAt: true, + error: true, + status: true, + }, + ...paginationArgs, + }); + + return paginateResults(input, documents); + }); + export const documentsRouter = { + all, list, get, delete: del, diff --git a/apps/web/src/server/orpc/internal/helpers.ts b/apps/web/src/server/orpc/router/helpers.ts similarity index 100% rename from apps/web/src/server/orpc/internal/helpers.ts rename to apps/web/src/server/orpc/router/helpers.ts diff --git a/apps/web/src/server/orpc/public/hosting.ts b/apps/web/src/server/orpc/router/hosting.ts similarity index 82% rename from apps/web/src/server/orpc/public/hosting.ts rename to apps/web/src/server/orpc/router/hosting.ts index b7ad8aaa..8d36cdab 100644 --- a/apps/web/src/server/orpc/public/hosting.ts +++ b/apps/web/src/server/orpc/router/hosting.ts @@ -1,3 +1,4 @@ +import type { ProtectedContext } from "@/server/orpc/base"; import { AgentsetApiError } from "@/lib/api/errors"; import { namespaceIdPathSchema } from "@/schemas/api/params"; import { @@ -7,7 +8,12 @@ import { HostingSchema, updateHostingSchema, } from "@/schemas/api/hosting"; -import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { + api, + protectedProcedure, + requireNamespace, + successSchema, +} from "@/server/orpc/base"; import { addDomain as addDomainService } from "@/services/domains/add"; import { checkDomainStatus as checkDomainStatusService } from "@/services/domains/check-status"; import { removeDomain as removeDomainService } from "@/services/domains/remove"; @@ -15,13 +21,15 @@ import { deleteHosting } from "@/services/hosting/delete"; import { enableHosting } from "@/services/hosting/enable"; import { getHosting } from "@/services/hosting/get"; import { updateHosting } from "@/services/hosting/update"; +import { ORPCError } from "@orpc/server"; import { z } from "zod/v4"; +import { db } from "@agentset/db/client"; import { prefixId } from "@agentset/utils"; import { makeCodeSamples, ts } from "./code-samples"; -const get = publicApi +const get = api .route({ method: "GET", path: "/namespace/{namespaceId}/hosting", @@ -63,7 +71,7 @@ console.log(hosting); }; }); -const enable = publicApi +const enable = api .route({ method: "POST", path: "/namespace/{namespaceId}/hosting", @@ -99,7 +107,7 @@ console.log(hosting); }; }); -const updateHandler = publicApi +const updateHandler = api .input(updateHostingSchema.extend({ namespaceId: namespaceIdPathSchema })) .use(requireNamespace, (input) => input.namespaceId) .output(successSchema(HostingSchema)) @@ -153,7 +161,7 @@ const updatePut = updateHandler.route({ tags: ["internal-alias"], }); -const del = publicApi +const del = api .route({ method: "DELETE", path: "/namespace/{namespaceId}/hosting", @@ -195,7 +203,7 @@ const getHostingOrThrow = async (namespaceId: string) => { return hosting; }; -const checkDomainStatus = publicApi +const checkDomainStatus = api .route({ method: "GET", path: "/namespace/{namespaceId}/hosting/domain", @@ -249,7 +257,7 @@ console.log(status); }; }); -const addDomain = publicApi +const addDomain = api .route({ method: "POST", path: "/namespace/{namespaceId}/hosting/domain", @@ -286,7 +294,7 @@ console.log(domain); return { success: true as const, data: domain }; }); -const removeDomain = publicApi +const removeDomain = api .route({ method: "DELETE", path: "/namespace/{namespaceId}/hosting/domain", @@ -316,6 +324,53 @@ console.log("Domain removed"); await removeDomainService({ hostingId: hosting.id }); }); +// ----------------------------------------------------------------------------- +// Dashboard-only (session) procedures — no .route(): never exposed on +// REST/OpenAPI/MCP. Raw outputs, raw ids. +// ----------------------------------------------------------------------------- + +/** + * Session counterpart of `getHostingOrThrow`: resolves the hosting through the + * caller's org membership (no org header contract) — kept inline because the + * org is derived from the namespace, not from an input. + */ +const getHostingByUserOrThrow = async ( + ctx: Pick, + input: { namespaceId: string }, +) => { + const hosting = await db.hosting.findFirst({ + where: { + namespace: { + id: input.namespaceId, + organization: { + members: { some: { userId: ctx.session.user.id } }, + }, + }, + }, + }); + + if (!hosting) { + throw new ORPCError("NOT_FOUND", { + message: "Hosting not found", + }); + } + + return hosting; +}; + +/** + * Raw Vercel domain-status payload for the dashboard's domain card. The shared + * `checkDomainStatus` flattens the response into `DomainStatusSchema`, which + * drops `domainJson.error.message` (rendered by the "Unknown Error" UI branch) + * and the raw `domainJson`/`configJson` the card consumes. + */ +const domainStatusDetailed = protectedProcedure + .input(z.object({ namespaceId: z.string() })) + .handler(async ({ context, input }) => { + const hosting = await getHostingByUserOrThrow(context, input); + return checkDomainStatusService({ hostingId: hosting.id }); + }); + export const hostingRouter = { get, enable, @@ -325,4 +380,5 @@ export const hostingRouter = { checkDomainStatus, addDomain, removeDomain, + domainStatusDetailed, }; diff --git a/apps/web/src/server/orpc/router/index.ts b/apps/web/src/server/orpc/router/index.ts new file mode 100644 index 00000000..c7357195 --- /dev/null +++ b/apps/web/src/server/orpc/router/index.ts @@ -0,0 +1,43 @@ +import { apiKeysRouter } from "./api-keys"; +import { billingRouter } from "./billing"; +import { chatRouter } from "./chat"; +import { documentsRouter } from "./documents"; +import { hostingRouter } from "./hosting"; +import { ingestJobsRouter } from "./ingest-jobs"; +import { namespacesRouter } from "./namespaces"; +import { organizationRouter } from "./organization"; +import { searchRouter } from "./search"; +import { uploadsRouter } from "./uploads"; +import { warmUpRouter } from "./warm-up"; +import { webhooksRouter } from "./webhooks"; + +/** + * The single app router, served on three surfaces: + * - dashboard RPC (`/api/rpc`, RPCHandler) — session auth; sees everything + * - public REST (`api.agentset.ai/v1`, OpenAPIHandler) — procedures with + * `.route()` metadata only; routing is driven by each procedure's + * `{ method, path }`, so the nesting here is organizational + * - MCP (`/api/mcp`) — tools generated from the procedures that carry an + * operationId (the published OpenAPI operations) + * + * Mount names match the old dashboard router so client query keys keep their + * path segments. Mount ORDER matches the old public router: the OpenAPI + * generator emits paths in traversal order, and the published document must + * not reorder. + */ +export const appRouter = { + organization: organizationRouter, + apiKey: apiKeysRouter, + namespace: namespacesRouter, + document: documentsRouter, + ingestJob: ingestJobsRouter, + upload: uploadsRouter, + search: searchRouter, + chat: chatRouter, + warmUp: warmUpRouter, + hosting: hostingRouter, + webhook: webhooksRouter, + billing: billingRouter, +}; + +export type AppRouter = typeof appRouter; diff --git a/apps/web/src/server/orpc/public/ingest-jobs.ts b/apps/web/src/server/orpc/router/ingest-jobs.ts similarity index 85% rename from apps/web/src/server/orpc/public/ingest-jobs.ts rename to apps/web/src/server/orpc/router/ingest-jobs.ts index 24117a25..112014fa 100644 --- a/apps/web/src/server/orpc/public/ingest-jobs.ts +++ b/apps/web/src/server/orpc/router/ingest-jobs.ts @@ -5,11 +5,17 @@ import { IngestJobSchema, } from "@/schemas/api/ingest-job"; import { jobIdPathSchema, namespaceIdPathSchema } from "@/schemas/api/params"; -import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { + api, + protectedProcedure, + requireNamespace, + successSchema, +} from "@/server/orpc/base"; import { createIngestJob } from "@/services/ingest-jobs/create"; import { deleteIngestJob } from "@/services/ingest-jobs/delete"; import { reIngestJob } from "@/services/ingest-jobs/re-ingest"; import { getPaginationArgs, paginateResults } from "@/services/pagination"; +import { ORPCError } from "@orpc/server"; import { z } from "zod/v4"; import { db } from "@agentset/db/client"; @@ -17,8 +23,9 @@ import { isFreePlan } from "@agentset/stripe/plans"; import { normalizeId, prefixId } from "@agentset/utils"; import { makeCodeSamples, ts } from "./code-samples"; +import { getNamespaceByUser } from "./helpers"; -const list = publicApi +const list = api .route({ method: "GET", path: "/namespace/{namespaceId}/ingest-jobs", @@ -93,7 +100,7 @@ console.log(jobs); }; }); -const create = publicApi +const create = api .route({ method: "POST", path: "/namespace/{namespaceId}/ingest-jobs", @@ -158,7 +165,7 @@ console.log(job); }; }); -const get = publicApi +const get = api .route({ method: "GET", path: "/namespace/{namespaceId}/ingest-jobs/{jobId}", @@ -219,7 +226,7 @@ console.log(job); }; }); -const del = publicApi +const del = api .route({ method: "DELETE", path: "/namespace/{namespaceId}/ingest-jobs/{jobId}", @@ -275,7 +282,7 @@ console.log("Ingest job queued for deletion"); }; }); -const reIngest = publicApi +const reIngest = api .route({ method: "POST", path: "/namespace/{namespaceId}/ingest-jobs/{jobId}/re-ingest", @@ -325,7 +332,58 @@ console.log("Job queued for re-ingestion: ", result); }; }); +/** + * Dashboard-only slim list ({ records, pagination }, raw un-prefixed ids, + * field subset). No `.route()` — stays off REST/OpenAPI/MCP. + */ +const all = protectedProcedure + .input(getIngestionJobsSchema.extend({ namespaceId: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const { where, ...paginationArgs } = getPaginationArgs( + input, + { + orderBy: input.orderBy, + order: input.order, + }, + "job_", + ); + + const ingestJobs = await db.ingestJob.findMany({ + where: { + namespaceId: input.namespaceId, + ...(input.statuses && + input.statuses.length > 0 && { + status: { in: input.statuses }, + }), + ...where, + }, + select: { + id: true, + status: true, + name: true, + tenantId: true, + completedAt: true, + failedAt: true, + error: true, + queuedAt: true, + createdAt: true, + }, + ...paginationArgs, + }); + + return paginateResults(input, ingestJobs); + }); + export const ingestJobsRouter = { + all, list, create, get, diff --git a/apps/web/src/server/orpc/public/namespaces.ts b/apps/web/src/server/orpc/router/namespaces.ts similarity index 54% rename from apps/web/src/server/orpc/public/namespaces.ts rename to apps/web/src/server/orpc/router/namespaces.ts index df9c8f06..be5979fd 100644 --- a/apps/web/src/server/orpc/public/namespaces.ts +++ b/apps/web/src/server/orpc/router/namespaces.ts @@ -4,19 +4,29 @@ import { NamespaceSchema, updateNamespaceSchema, } from "@/schemas/api/namespace"; -import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { + api, + protectedProcedure, + requireMember, + requireNamespace, + requireRoles, + successSchema, +} from "@/server/orpc/base"; import { createNamespace } from "@/services/namespaces/create"; import { deleteNamespace } from "@/services/namespaces/delete"; import { updateNamespace } from "@/services/namespaces/update"; +import { ORPCError } from "@orpc/server"; import { z } from "zod/v4"; import { NamespaceStatus } from "@agentset/db"; import { db } from "@agentset/db/client"; +import { getDemoTemplate } from "@agentset/demo"; +import { triggerSeedDemoNamespace } from "@agentset/jobs"; import { prefixId } from "@agentset/utils"; import { makeCodeSamples, ts } from "./code-samples"; -const list = publicApi +const list = api .route({ method: "GET", path: "/namespace", @@ -60,7 +70,8 @@ console.log(namespaces); }; }); -const create = publicApi +const create = api + .use(requireRoles("admin", "owner")) .route({ method: "POST", path: "/namespace", @@ -107,7 +118,7 @@ console.log(namespace); }; }); -const get = publicApi +const get = api .route({ method: "GET", path: "/namespace/{namespaceId}", @@ -142,7 +153,7 @@ console.log(namespace); }; }); -const updateHandler = publicApi +const updateHandler = api .input(updateNamespaceSchema.extend({ namespaceId: namespaceIdPathSchema })) .use(requireNamespace, (input) => input.namespaceId) .output(successSchema(NamespaceSchema)) @@ -195,7 +206,8 @@ const updatePut = updateHandler.route({ tags: ["internal-alias"], }); -const del = publicApi +const del = api + .use(requireRoles("admin", "owner")) .route({ method: "DELETE", path: "/namespace/{namespaceId}", @@ -234,4 +246,187 @@ export const namespacesRouter = { update, updatePut, delete: del, + + // --------------------------------------------------------------------- + // Dashboard-only procedures — session auth, no REST/OpenAPI/MCP surface. + // --------------------------------------------------------------------- + getOrgNamespaces: protectedProcedure + .input( + z.object({ + slug: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const member = await requireMember(context.session, { + slug: input.slug, + }); + + const namespaces = await db.namespace.findMany({ + where: { + organizationId: member.organizationId, + status: NamespaceStatus.ACTIVE, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return namespaces; + }), + getNamespaceBySlug: protectedProcedure + .input(z.object({ orgSlug: z.string(), slug: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await db.namespace.findFirst({ + where: { + slug: input.slug, + organization: { + slug: input.orgSlug, + members: { some: { userId: context.session.user.id } }, + }, + status: NamespaceStatus.ACTIVE, + }, + }); + + return namespace; + }), + getOnboardingStatus: protectedProcedure + .input(z.object({ orgSlug: z.string(), slug: z.string() })) + .handler(async ({ context, input }) => { + const namespace = await db.namespace.findFirst({ + where: { + slug: input.slug, + organization: { + slug: input.orgSlug, + members: { some: { userId: context.session.user.id } }, + }, + status: NamespaceStatus.ACTIVE, + }, + select: { + totalIngestJobs: true, + totalPlaygroundUsage: true, + organization: { + select: { + apiKeys: { + take: 1, + select: { + id: true, + }, + }, + }, + }, + }, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + return { + ingestDocuments: namespace.totalIngestJobs > 0, + playground: namespace.totalPlaygroundUsage > 0, + createApiKey: namespace.organization.apiKeys.length > 0, + }; + }), + checkSlug: protectedProcedure + .input( + z.object({ + orgId: z.string(), + slug: z.string(), + }), + ) + .handler(async ({ input }) => { + const namespace = await db.namespace.findUnique({ + where: { + organizationId_slug: { + slug: input.slug, + organizationId: input.orgId, + }, + }, + }); + + return !!namespace; + }), + createDemoNamespace: protectedProcedure + .input( + z.object({ + orgId: z.string(), + templateId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + await requireMember( + context.session, + { id: input.orgId }, + { roles: ["admin", "owner"] }, + ); + + const template = getDemoTemplate(input.templateId); + if (!template) { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid template ID", + }); + } + + const organization = await db.organization.findUnique({ + where: { id: input.orgId }, + select: { id: true, plan: true }, + }); + if (!organization) { + throw new ORPCError("NOT_FOUND"); + } + + let slug: string = template.id; + let suffix = 2; + while (true) { + const existing = await db.namespace.findUnique({ + where: { + organizationId_slug: { + organizationId: input.orgId, + slug, + }, + }, + select: { id: true }, + }); + + if (!existing) break; + + slug = `${template.id}-${suffix}`; + suffix += 1; + } + + const [namespace] = await db.$transaction([ + db.namespace.create({ + data: { + name: template.name, + slug, + demoId: template.id, + organizationId: input.orgId, + embeddingConfig: { + provider: "MANAGED_OPENAI", + model: "text-embedding-3-large", + }, + vectorStoreConfig: { + provider: "MANAGED_TURBOPUFFER", + }, + }, + }), + db.organization.update({ + where: { id: input.orgId }, + data: { + totalNamespaces: { increment: 1 }, + }, + }), + ]); + + await triggerSeedDemoNamespace( + { + namespaceId: namespace.id, + organizationId: organization.id, + templateId: template.id, + }, + organization.plan, + ); + + return namespace; + }), }; diff --git a/apps/web/src/server/orpc/public/organization.ts b/apps/web/src/server/orpc/router/organization.ts similarity index 52% rename from apps/web/src/server/orpc/public/organization.ts rename to apps/web/src/server/orpc/router/organization.ts index b29e044d..f7c12ee4 100644 --- a/apps/web/src/server/orpc/public/organization.ts +++ b/apps/web/src/server/orpc/router/organization.ts @@ -1,20 +1,26 @@ import { AgentsetApiError } from "@/lib/api/errors"; -import { publicApi, successSchema } from "@/server/orpc/base"; import { OrganizationMembersSchema, OrganizationSchema, updateOrganizationSchema, } from "@/schemas/api/organization"; +import { api, protectedProcedure, successSchema } from "@/server/orpc/base"; +import { deleteOrganization } from "@/services/organizations/delete"; import { getOrganization, toOrganizationResponse, } from "@/services/organizations/get"; import { getOrganizationMembers } from "@/services/organizations/members"; import { updateOrganization } from "@/services/organizations/update"; +import { ORPCError } from "@orpc/server"; +import { z } from "zod/v4"; + +import { OrganizationStatus } from "@agentset/db"; +import { db } from "@agentset/db/client"; import { makeCodeSamples, ts } from "./code-samples"; -const get = publicApi +const get = api .route({ method: "GET", path: "/organization", @@ -52,7 +58,7 @@ console.log(organization); return { success: true as const, data: toOrganizationResponse(org) }; }); -const updateHandler = publicApi +const updateHandler = api .input(updateOrganizationSchema) .output(successSchema(OrganizationSchema)) .handler(async ({ context, input }) => { @@ -99,7 +105,7 @@ const updatePut = updateHandler.route({ tags: ["internal-alias"], }); -const members = publicApi +const listMembers = api .route({ method: "GET", path: "/organization/members", @@ -138,8 +144,130 @@ console.log(members); }); export const organizationRouter = { + // shared (REST + MCP + dashboard) get, update, updatePut, - members, + listMembers, + + // dashboard-only (session, no .route() — never on REST/OpenAPI/MCP) + all: protectedProcedure.handler(async ({ context }) => { + const orgs = await db.organization.findMany({ + where: { + members: { + some: { + userId: context.session.user.id, + }, + }, + status: OrganizationStatus.ACTIVE, + }, + select: { + id: true, + name: true, + slug: true, + plan: true, + logo: true, + namespaces: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }); + + return orgs; + }), + getBySlug: protectedProcedure + .input(z.object({ slug: z.string() })) + .handler(async ({ context, input }) => { + const org = await db.organization.findUnique({ + where: { + slug: input.slug, + members: { + some: { + userId: context.session.user.id, + }, + }, + }, + include: { + members: { + where: { + userId: context.session.user.id, + }, + take: 1, + select: { + id: true, + role: true, + }, + }, + }, + }); + + if (!org) { + throw new ORPCError("NOT_FOUND"); + } + + const { members } = org; + const isAdmin = + members[0]?.role === "admin" || members[0]?.role === "owner"; + + return { + ...org, + isAdmin, + isOwner: members[0]?.role === "owner", + currentMemberId: members[0]?.id, + }; + }), + members: protectedProcedure + .input( + z.object({ + organizationId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const members = await getOrganizationMembers({ + organizationId: input.organizationId, + userId: context.session.user.id, + }); + + return members; + }), + delete: protectedProcedure + .input( + z.object({ + organizationId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + const org = await db.organization.findUnique({ + where: { + id: input.organizationId, + members: { + some: { + userId: context.session.user.id, + role: { in: ["admin", "owner"] }, + }, + }, + }, + }); + + if (!org) { + throw new ORPCError("UNAUTHORIZED", { + message: "You are not authorized to delete this organization", + }); + } + + if (org.status === OrganizationStatus.DELETING) { + throw new ORPCError("BAD_REQUEST", { + message: "Organization is already being deleted", + }); + } + + await deleteOrganization({ organizationId: org.id }); + }), }; diff --git a/apps/web/src/server/orpc/public/search.ts b/apps/web/src/server/orpc/router/search.ts similarity index 60% rename from apps/web/src/server/orpc/public/search.ts rename to apps/web/src/server/orpc/router/search.ts index cfa06495..33a34506 100644 --- a/apps/web/src/server/orpc/public/search.ts +++ b/apps/web/src/server/orpc/router/search.ts @@ -5,18 +5,30 @@ import { import { NodeSchema } from "@/schemas/api/node"; import { namespaceIdPathSchema } from "@/schemas/api/params"; import { queryVectorStoreSchema } from "@/schemas/api/query"; -import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { + api, + protectedProcedure, + requireNamespace, + successSchema, +} from "@/server/orpc/base"; import { searchNamespace } from "@/services/search"; import { toOpenAPISchema } from "@orpc/openapi"; -import { type } from "@orpc/server"; +import { ORPCError, type } from "@orpc/server"; import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; import { z } from "zod/v4"; import type { QueryVectorStoreResult } from "@agentset/engine"; +import { + getNamespaceEmbeddingModel, + getNamespaceVectorStore, + queryVectorStore, +} from "@agentset/engine"; +import { rerankerSchema } from "@agentset/validation"; import { makeCodeSamples, ts } from "./code-samples"; +import { getNamespaceByUser } from "./helpers"; -const execute = publicApi +const search = api .route({ method: "POST", path: "/namespace/{namespaceId}/search", @@ -79,6 +91,52 @@ console.log(results); return { success: true as const, data: results }; }); +const chunkExplorerInputSchema = z.object({ + namespaceId: z.string(), + query: z.string().min(1), + topK: z.number().min(1).max(100), + rerank: z.boolean(), + rerankModel: rerankerSchema, + rerankLimit: z.number().min(1).max(100), + filter: z.record(z.string(), z.any()).optional(), +}); + +const playground = protectedProcedure + .input(chunkExplorerInputSchema) + .handler(async ({ context, input }) => { + const namespace = await getNamespaceByUser(context, { + id: input.namespaceId, + }); + + if (!namespace) { + throw new ORPCError("NOT_FOUND"); + } + + const [embeddingModel, vectorStore] = await Promise.all([ + getNamespaceEmbeddingModel(namespace, "query"), + getNamespaceVectorStore(namespace), + ]); + + const queryResult = await queryVectorStore({ + query: input.query, + topK: input.topK, + filter: input.filter, + includeMetadata: true, + rerank: input.rerank + ? { model: input.rerankModel, limit: input.rerankLimit } + : false, + embeddingModel, + vectorStore, + consistency: "strong", + }); + + // Track search usage + incrementOrganizationSearchUsage(namespace.organizationId, 1); + + return queryResult.results; + }); + export const searchRouter = { - execute, + search, + playground, }; diff --git a/apps/web/src/server/orpc/public/uploads.ts b/apps/web/src/server/orpc/router/uploads.ts similarity index 97% rename from apps/web/src/server/orpc/public/uploads.ts rename to apps/web/src/server/orpc/router/uploads.ts index 6df7bbaf..de5a2522 100644 --- a/apps/web/src/server/orpc/public/uploads.ts +++ b/apps/web/src/server/orpc/router/uploads.ts @@ -5,13 +5,13 @@ import { uploadFileSchema, UploadResultSchema, } from "@/schemas/api/upload"; -import { publicApi, requireNamespace, successSchema } from "@/server/orpc/base"; +import { api, requireNamespace, successSchema } from "@/server/orpc/base"; import { createBatchUpload, createUpload } from "@/services/uploads"; import { z } from "zod/v4"; import { makeCodeSamples, ts } from "./code-samples"; -const create = publicApi +const create = api .route({ method: "POST", path: "/namespace/{namespaceId}/uploads", @@ -78,7 +78,7 @@ console.log("Uploaded successfully: ", result.key); return { success: true as const, data: result.data }; }); -const createBatch = publicApi +const createBatch = api .route({ method: "POST", path: "/namespace/{namespaceId}/uploads/batch", diff --git a/apps/web/src/server/orpc/public/warm-up.ts b/apps/web/src/server/orpc/router/warm-up.ts similarity index 96% rename from apps/web/src/server/orpc/public/warm-up.ts rename to apps/web/src/server/orpc/router/warm-up.ts index b0edc247..6f5d4b56 100644 --- a/apps/web/src/server/orpc/public/warm-up.ts +++ b/apps/web/src/server/orpc/router/warm-up.ts @@ -1,6 +1,6 @@ import { AgentsetApiError } from "@/lib/api/errors"; import { namespaceIdPathSchema } from "@/schemas/api/params"; -import { publicApi, requireNamespace } from "@/server/orpc/base"; +import { api, requireNamespace } from "@/server/orpc/base"; import { type } from "@orpc/server"; import { z } from "zod/v4"; @@ -8,7 +8,7 @@ import { getNamespaceVectorStore } from "@agentset/engine"; import { makeCodeSamples, ts } from "./code-samples"; -const warmUp = publicApi +const warmUp = api .route({ method: "POST", path: "/namespace/{namespaceId}/warm-up", diff --git a/apps/web/src/server/orpc/public/webhooks.ts b/apps/web/src/server/orpc/router/webhooks.ts similarity index 68% rename from apps/web/src/server/orpc/public/webhooks.ts rename to apps/web/src/server/orpc/router/webhooks.ts index 61d15bba..e6fb7669 100644 --- a/apps/web/src/server/orpc/public/webhooks.ts +++ b/apps/web/src/server/orpc/router/webhooks.ts @@ -1,4 +1,5 @@ import { AgentsetApiError } from "@/lib/api/errors"; +import { samplePayload } from "@/lib/webhook/sample-events/payload"; import { createWebhookSchema, updateWebhookSchema, @@ -6,7 +7,13 @@ import { WebhookSchema, WebhookSummarySchema, } from "@/schemas/api/webhook"; -import { publicApi, successSchema } from "@/server/orpc/base"; +import { + api, + protectedProcedure, + requireMember, + requireRoles, + successSchema, +} from "@/server/orpc/base"; import { createWebhook } from "@/services/webhooks/create"; import { deleteWebhook } from "@/services/webhooks/delete"; import { getWebhook } from "@/services/webhooks/get"; @@ -14,10 +21,20 @@ import { listWebhooks } from "@/services/webhooks/list"; import { requireWebhooksPlan } from "@/services/webhooks/plan"; import { regenerateWebhookSecret } from "@/services/webhooks/regenerate-secret"; import { applyWebhookUpdate } from "@/services/webhooks/update"; +import { ORPCError } from "@orpc/server"; +import { nanoid } from "nanoid"; import { z } from "zod/v4"; import type { WebhookProps } from "@agentset/webhooks"; +import { db } from "@agentset/db/client"; +import { triggerSendWebhook } from "@agentset/jobs"; +import { getWebhookEvents } from "@agentset/tinybird"; import { normalizeId, prefixId } from "@agentset/utils"; +import { + WEBHOOK_EVENT_ID_PREFIX, + WEBHOOK_TRIGGERS, + webhookPayloadSchema, +} from "@agentset/webhooks"; import { makeCodeSamples, ts } from "./code-samples"; @@ -59,7 +76,7 @@ const updateWebhookInputSchema = updateWebhookSchema }, ); -const list = publicApi +const list = api .route({ method: "GET", path: "/webhooks", @@ -93,7 +110,8 @@ console.log(webhooks); }; }); -const create = publicApi +const create = api + .use(requireRoles("admin", "owner")) .route({ method: "POST", path: "/webhooks", @@ -139,7 +157,7 @@ console.log(webhook); }; }); -const get = publicApi +const get = api .route({ method: "GET", path: "/webhooks/{webhookId}", @@ -179,7 +197,8 @@ console.log(webhook); }; }); -const updateHandler = publicApi +const updateHandler = api + .use(requireRoles("admin", "owner")) .input(updateWebhookInputSchema) .output(successSchema(WebhookSchema)) .handler(async ({ context, input }) => { @@ -231,7 +250,8 @@ const updatePut = updateHandler.route({ tags: ["internal-alias"], }); -const del = publicApi +const del = api + .use(requireRoles("admin", "owner")) .route({ method: "DELETE", path: "/webhooks/{webhookId}", @@ -267,7 +287,8 @@ console.log("Webhook deleted"); } }); -const regenerateSecret = publicApi +const regenerateSecret = api + .use(requireRoles("admin", "owner")) .route({ method: "POST", path: "/webhooks/{webhookId}/regenerate-secret", @@ -308,6 +329,120 @@ console.log(webhook.secret); }; }); +/** + * --------------------------------------------------------------------------- + * Dashboard-only procedures — session auth, no `.route()` metadata (kept off + * REST, the generated spec, and MCP). + * --------------------------------------------------------------------------- + */ + +// List all webhooks for an organization (raw output, includes secret) +const listByOrg = protectedProcedure + .input(z.object({ organizationId: z.string() })) + .handler(async ({ context, input }) => { + await requireMember(context.session, { id: input.organizationId }); + + return listWebhooks({ organizationId: input.organizationId }); + }); + +// Get webhook events from Tinybird +const getEvents = protectedProcedure + .input(z.object({ organizationId: z.string(), webhookId: z.string() })) + .handler(async ({ context, input }) => { + await requireMember(context.session, { id: input.organizationId }); + + // Verify webhook belongs to org + const webhook = await db.webhook.findUnique({ + where: { + id: input.webhookId, + organizationId: input.organizationId, + }, + select: { id: true }, + }); + + if (!webhook) { + throw new ORPCError("NOT_FOUND"); + } + + const events = await getWebhookEvents({ webhookId: input.webhookId }); + return events.data; + }); + +// Send test webhook event +const sendTest = protectedProcedure + .input( + z.object({ + organizationId: z.string(), + webhookId: z.string(), + trigger: z.enum(WEBHOOK_TRIGGERS), + }), + ) + .handler(async ({ context, input }) => { + await requireMember( + context.session, + { id: input.organizationId }, + { roles: ["admin", "owner"] }, + ); + + const webhook = await db.webhook.findUnique({ + where: { + id: input.webhookId, + organizationId: input.organizationId, + }, + select: { + id: true, + url: true, + secret: true, + }, + }); + + if (!webhook) { + throw new ORPCError("NOT_FOUND"); + } + + const payload = webhookPayloadSchema.parse({ + id: `${WEBHOOK_EVENT_ID_PREFIX}${nanoid(25)}`, + event: input.trigger, + createdAt: new Date().toISOString(), + data: samplePayload[input.trigger], + }); + + await triggerSendWebhook({ + webhookId: webhook.id, + eventId: payload.id, + event: input.trigger, + url: webhook.url, + secret: webhook.secret, + payload, + }); + + return { success: true }; + }); + +// Get namespaces for webhook namespace selector +const getNamespaces = protectedProcedure + .input(z.object({ organizationId: z.string() })) + .handler(async ({ context, input }) => { + await requireMember(context.session, { id: input.organizationId }); + + const namespaces = await db.namespace.findMany({ + where: { + organizationId: input.organizationId, + status: "ACTIVE", + }, + select: { + id: true, + name: true, + slug: true, + }, + orderBy: { + name: "asc", + }, + }); + + return namespaces; + }); + export const webhooksRouter = { list, create, @@ -316,4 +451,8 @@ export const webhooksRouter = { updatePut, delete: del, regenerateSecret, + listByOrg, + getEvents, + sendTest, + getNamespaces, }; diff --git a/apps/web/src/server/orpc/spec.ts b/apps/web/src/server/orpc/spec.ts index fa0cd02d..fb375d47 100644 --- a/apps/web/src/server/orpc/spec.ts +++ b/apps/web/src/server/orpc/spec.ts @@ -58,11 +58,11 @@ import { import { webhookEventSchema } from "@agentset/webhooks"; import { successSchema } from "./base"; -import { publicRouter } from "./public/router"; +import { appRouter } from "./router"; /** - * Builds the public OpenAPI document (`/openapi.json`) from the oRPC public - * router, then post-processes it into byte-level parity with the legacy + * Builds the public OpenAPI document (`/openapi.json`) from the app router, + * then post-processes it into byte-level parity with the legacy * zod-openapi document. Speakeasy (SDK generation) and Mintlify (docs) ingest * this document, so operationIds, `x-speakeasy-*` extensions, component * schema NAMES and the shared error/parameter components must not drift. @@ -941,13 +941,14 @@ const generator = new OpenAPIGenerator({ }); export const buildOpenApiDocument = async (): Promise => { - const document = await generator.generate(publicRouter, { + const document = await generator.generate(appRouter, { info: INFO, servers: SERVERS, components: { securitySchemes: SECURITY_SCHEMES as never }, commonSchemas: commonSchemas as never, - filter: ({ contract }) => - !contract["~orpc"].route.tags?.includes("internal-alias"), + // published operations only: dashboard-only procedures have no route + // metadata and the hidden PUT aliases have no operationId + filter: ({ contract }) => !!contract["~orpc"].route.operationId, }); return postProcess(document as unknown as JsonObject);