diff --git a/.gitignore b/.gitignore index cdf15085..9d56de5c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,11 @@ dist/ # turbo .turbo -*.local.* \ No newline at end of file +*.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/ 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 a36d8f8b..15255c3a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,12 +36,16 @@ "@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", + "@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", @@ -52,6 +56,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", @@ -61,14 +66,12 @@ "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", "virtua": "^0.48.3", "zod": "catalog:", "zod-error": "catalog:", - "zod-openapi": "catalog:", "zustand": "^5.0.10" }, "devDependencies": { 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..6186ea77 --- /dev/null +++ b/apps/web/src/app/api/(internal-api)/rpc/[[...rest]]/route.ts @@ -0,0 +1,72 @@ +import type { ApiContext } from "@/server/orpc/base"; +import { auth } from "@/lib/auth"; +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, + RequestHeadersPlugin, +} from "@orpc/server/plugins"; + +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); + }), + ], +}); + +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: ApiContext = { + resHeaders: {}, + analytics: {}, + 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/(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/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/[...rest]/route.ts b/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts new file mode 100644 index 00000000..4821571b --- /dev/null +++ b/apps/web/src/app/api/(public-api)/v1/[...rest]/route.ts @@ -0,0 +1,96 @@ +import type { ApiContext } 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 { appRouter } from "@/server/orpc/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(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()], + }), + ], + 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: ApiContext = { + reqHeaders: 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/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 12fae5bd..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/chunks-download-url/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -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"; - -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 }, - }); - - 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 }, - 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 80cc5318..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/file-download-url/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -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"; - -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, - }); - - return makeApiSuccessResponse({ - data: { url }, - 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 8ab5dc21..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/documents/[documentId]/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 { DocumentStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { normalizeId, 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, - }, - }); - - if (!doc) { - throw new AgentsetApiError({ - code: "not_found", - message: "Document not found", - }); - } - - 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, headers }) => { - const documentId = normalizeId(params.documentId ?? "", "doc_"); - if (!documentId) { - throw new AgentsetApiError({ - code: "bad_request", - message: "Invalid document ID", - }); - } - - // 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, - organizationId: namespace.organizationId, - }); - - 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/route.ts b/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts deleted file mode 100644 index 01527bdc..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/hosting/route.ts +++ /dev/null @@ -1,98 +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 }) => { - 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, - }); - }, - { 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 07e5ced9..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/re-ingest/route.ts +++ /dev/null @@ -1,90 +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 { IngestJobStatus } from "@agentset/db"; -import { db } from "@agentset/db/client"; -import { triggerReIngestJob } from "@agentset/jobs"; - -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 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, - }, - }); - - return makeApiSuccessResponse({ - data: { - id: prefixId(ingestJob.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 a2ddd1c4..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/[jobId]/route.ts +++ /dev/null @@ -1,108 +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 { IngestJobStatus } from "@agentset/db"; -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 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, - 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 1b609184..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/ingest-jobs/route.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { AgentsetApiError, exceededLimitError } 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 { Prisma } from "@agentset/db"; -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 }) => { - // 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") { - 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", - }); - } - } - - throw error; - } - }, - { 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 f67bf0d1..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/route.ts +++ /dev/null @@ -1,90 +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 { - NamespaceSchema, - updateNamespaceSchema, -} from "@/schemas/api/namespace"; -import { deleteNamespace } from "@/services/namespaces/delete"; - -import { Prisma } from "@agentset/db"; -import { db } from "@agentset/db/client"; - -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 { name, slug } = 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.`, - }); - } - - // let the default error handler handle the error - throw error; - } - }, - { 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 makeApiSuccessResponse({ - data: NamespaceSchema.parse({ - ...namespace, - id: prefixId(namespace.id, "ns_"), - organizationId: prefixId(namespace.organizationId, "org_"), - }), - headers, - status: 204, - }); - }, - { 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 516a28b8..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/search/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { AgentsetApiError, exceededLimitError } from "@/lib/api/errors"; -import { withNamespaceApiHandler } from "@/lib/api/handler"; -import { makeApiSuccessResponse } from "@/lib/api/response"; -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"; - -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", - }), - }); - } - - 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", - }); - } - - waitUntil( - (async () => { - // track usage - await db.organization.update({ - where: { - id: organization.id, - }, - data: { - searchUsage: { increment: 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 75784ffe..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/batch/route.ts +++ /dev/null @@ -1,38 +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, organization, headers, req }) => { - const { files } = await batchUploadSchema.parseAsync( - await parseRequestBody(req), - ); - - const result = await createBatchUpload({ - namespaceId: namespace.id, - plan: organization.plan, - files, - }); - - if (!result.success) { - throw new AgentsetApiError({ - code: - result.code === "file_too_large" - ? "rate_limit_exceeded" - : "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 2eac9096..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/[namespaceId]/uploads/route.ts +++ /dev/null @@ -1,36 +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, organization, headers, req }) => { - const { fileName, contentType, fileSize } = - await uploadFileSchema.parseAsync(await parseRequestBody(req)); - - const result = await createUpload({ - namespaceId: namespace.id, - plan: organization.plan, - file: { fileName, contentType, fileSize }, - }); - - if (!result.success) { - throw new AgentsetApiError({ - code: - result.code === "file_too_large" - ? "rate_limit_exceeded" - : "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 4b0b8219..00000000 --- a/apps/web/src/app/api/(public-api)/v1/namespace/route.ts +++ /dev/null @@ -1,103 +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 { - createNamespaceSchema, - NamespaceSchema, -} from "@/schemas/api/namespace"; -import { createNamespace } from "@/services/namespaces/create"; -import { - validateEmbeddingModel, - validateVectorStoreConfig, -} from "@/services/namespaces/validate"; - -import { Prisma } 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, - }, - 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), - ); - - 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.`, - }); - } - - // let the default error handler handle the error - throw error; - } - }, - { logging: { routeName: "POST /v1/namespace" } }, -); 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..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,7 +2,8 @@ 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 { useOrganization } from "@/hooks/use-organization"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CopyIcon, @@ -27,19 +28,21 @@ import type { JobCol } from "./columns"; export function JobActions({ row }: { row: Row }) { const queryClient = useQueryClient(); const namespace = useNamespace(); - const trpc = useTRPC(); + const organization = useOrganization(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { mutate: deleteJob, isPending: isDeletePending } = useMutation( - trpc.ingestJob.delete.mutationOptions({ + orpc.ingestJob.delete.mutationOptions({ + context: { orgId: organization.id }, 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 +51,16 @@ export function JobActions({ row }: { row: Row }) { ); const { mutate: reIngestJob, isPending: isReIngestPending } = useMutation( - trpc.ingestJob.reIngest.mutationOptions({ + orpc.ingestJob.reIngest.mutationOptions({ + context: { orgId: organization.id }, 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..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,7 +2,8 @@ import { useMemo, useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; -import { trpcClient } from "@/trpc/react"; +import { useOrganization } from "@/hooks/use-organization"; +import { client } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { ChevronDownIcon, CopyIcon, SearchIcon } from "lucide-react"; import { toast } from "sonner"; @@ -37,25 +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 trpcClient.document.getChunksDownloadUrl.mutate( + 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 6325bd23..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,6 +1,7 @@ import { useMemo, useState } from "react"; import { useNamespace } from "@/hooks/use-namespace"; -import { useTRPC } from "@/trpc/react"; +import { useOrganization } from "@/hooks/use-organization"; +import { orpc } from "@/lib/orpc"; import { useQuery } from "@tanstack/react-query"; import { SingleLanguageCodeBlock } from "@agentset/ui/ai/code-block"; @@ -16,16 +17,18 @@ import { Skeleton } from "@agentset/ui/skeleton"; export function ConfigModal({ jobId }: { jobId: string }) { const [open, setOpen] = useState(false); - const trpc = useTRPC(); const namespace = useNamespace(); + const organization = useOrganization(); const { data: config, isLoading } = useQuery({ - ...trpc.ingestJob.getConfig.queryOptions( - { + ...orpc.ingestJob.get.queryOptions({ + input: { jobId, namespaceId: namespace.id, }, - { enabled: open }, - ), + context: { orgId: organization.id }, + enabled: open, + select: (res) => res.data.config, + }), }); 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..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,8 +2,9 @@ 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 { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CopyIcon, @@ -28,12 +29,13 @@ import type { DocumentCol } from "./documents-columns"; export default function DocumentActions({ row }: { row: Row }) { const namespace = useNamespace(); - const trpc = useTRPC(); + const organization = useOrganization(); const queryClient = useQueryClient(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { isPending, mutate: deleteDocument } = useMutation( - trpc.document.delete.mutationOptions({ + orpc.document.delete.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { logEvent("document_deleted", { documentId: row.original.id, @@ -42,11 +44,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,9 +58,10 @@ export default function DocumentActions({ row }: { row: Row }) { ); const { isPending: isDownloading, mutate: getDownloadUrl } = useMutation( - trpc.document.getFileDownloadUrl.mutationOptions({ - onSuccess: ({ url }) => { - window.open(url, "_blank"); + orpc.document.getFileDownloadUrl.mutationOptions({ + 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 82301b63..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 @@ -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.create.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..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,6 +1,7 @@ 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 { useMutation } from "@tanstack/react-query"; import { IngestJobConfig, IngestJobPayload } from "@agentset/validation"; @@ -42,18 +43,20 @@ export function useIngest({ extraAnalytics, }: UseIngestOptions) { const namespace = useNamespace(); - const trpc = useTRPC(); + const organization = useOrganization(); const mutation = useMutation( - trpc.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]/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..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,8 +1,9 @@ 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 { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircleIcon, @@ -26,15 +27,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.hosting.domainStatusDetailed.queryOptions({ + input: { namespaceId: namespace.id }, + refetchInterval: 20000, + }), ); return { @@ -231,21 +229,22 @@ const DomainControls = ({ }) => { const { refetch, loading } = useDomainStatus(); const namespace = useNamespace(); - const trpc = useTRPC(); + const organization = useOrganization(); const queryClient = useQueryClient(); const { mutate: removeDomain, isPending: isRemovingDomain } = useMutation( - trpc.domain.remove.mutationOptions({ + orpc.hosting.removeDomain.mutationOptions({ + context: { orgId: organization.id }, 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,17 +284,18 @@ export function CustomDomainConfigurator(props: { defaultDomain?: string }) { props.defaultDomain ?? "", ); - const trpc = useTRPC(); const namespace = useNamespace(); + const organization = useOrganization(); const { mutate: addDomain, isPending } = useMutation( - trpc.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 346a2be9..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,6 +1,7 @@ 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 { Button } from "@agentset/ui/button"; import { @@ -18,20 +19,24 @@ import { toast } from "sonner"; export function EmptyState() { const namespace = useNamespace(); + const organization = useOrganization(); - const trpc = useTRPC(); const queryClient = useQueryClient(); const { mutate: enableHosting, isPending } = useMutation( - trpc.hosting.enable.mutationOptions({ - onSuccess: (hosting) => { + orpc.hosting.enable.mutationOptions({ + context: { orgId: organization.id }, + onSuccess: (res) => { 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 }; + // 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 f6d2583e..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 { useTRPC } from "@/trpc/react"; +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,12 +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 trpc = useTRPC(); - const { data, isLoading } = useQuery( - trpc.hosting.get.queryOptions({ - namespaceId: namespace.id, + 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), }), ); @@ -22,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 24c6ad1d..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,23 +1,19 @@ -import type { RouterOutputs } from "@/trpc/react"; +import type { RouterOutputs } from "@/lib/orpc"; import { useNamespace } from "@/hooks/use-namespace"; +import { useOrganization } from "@/hooks/use-organization"; import { AGENTIC_SYSTEM_PROMPT, isKnownDefaultPrompt, } from "@/lib/agentic-search/prompts"; import { logEvent } from "@/lib/analytics"; -import { useTRPC } from "@/trpc/react"; +import { orpc } from "@/lib/orpc"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQueryClient } from "@tanstack/react-query"; 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,45 +39,50 @@ 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 trpc = useTRPC(); + const organization = useOrganization(); const queryClient = useQueryClient(); const { mutateAsync: updateHosting, isPending: isUpdating } = useMutation( - trpc.hosting.update.mutationOptions({ + 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( - trpc.hosting.get.queryKey({ - namespaceId: namespace.id, + orpc.hosting.get.queryKey({ + 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, + }, }; }, ); - 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); @@ -115,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, }, }); @@ -127,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/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..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 @@ -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.playground.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..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 @@ -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,11 @@ 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.delete.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { logEvent("namespace_deleted", { id: namespace.id, @@ -27,8 +27,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/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..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 @@ -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,25 @@ 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.delete.mutationOptions({ + // rows come from the session getApiKeys cache, so organizationId is raw + context: { orgId }, 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"); }, @@ -40,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 90751251..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 @@ -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,21 @@ 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({ - onSuccess: (newKey) => { + orpc.apiKey.create.mutationOptions({ + context: { orgId }, + onSuccess: (res) => { logEvent("api_key_created", { orgId, - scope: newKey.scope, - }); - - const queryFilter = trpc.apiKey.getApiKeys.queryFilter({ orgId }); - - queryClient.setQueryData(queryFilter.queryKey, (old) => { - if (!old) return []; - return [...old, newKey]; + scope: res.data.scope, }); 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); @@ -60,7 +55,6 @@ export default function CreateApiKey({ orgId }: { orgId: string }) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await mutateAsync({ - organizationId: orgId, label, scope, }); @@ -96,10 +90,10 @@ export default function CreateApiKey({ orgId }: { orgId: string }) {
-              {data.key}
+              {data.data.key}
               
             
@@ -122,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]/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..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 @@ -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,12 @@ 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: { webhookId: params.webhookId }, + context: { orgId: organization.id }, + select: (r) => r.data, }), ); 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/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..d7b5437c 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"; @@ -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"; @@ -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,12 +87,14 @@ export default function CreateNamespaceDialog({ const [slug, setSlug] = useState(defaultSlug); const { isPending, mutateAsync: createNamespace } = useMutation( - trpc.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, @@ -115,10 +116,9 @@ 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 }); router.push(`/${organization.slug}/${data.slug}/quick-start`); } @@ -133,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/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..3d670841 100644 --- a/apps/web/src/components/webhooks/add-edit-webhook-form.tsx +++ b/apps/web/src/components/webhooks/add-edit-webhook-form.tsx @@ -2,12 +2,13 @@ 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"; 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"; @@ -61,7 +62,6 @@ export default function AddEditWebhookForm({ }: AddEditWebhookFormProps) { const router = useRouter(); const queryClient = useQueryClient(); - const trpc = useTRPC(); const isEditing = !!webhook; const form = useZodForm(webhookFormSchema, { @@ -69,20 +69,26 @@ 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_")) || [], }, }); 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({ + context: { orgId: organizationId }, onSuccess: () => { toast.success("Webhook created"); queryClient.invalidateQueries({ - queryKey: trpc.webhook.list.queryKey({ organizationId }), + queryKey: orpc.webhook.listByOrg.queryKey({ + input: { organizationId }, + }), }); router.push(`/${organizationSlug}/webhooks`); }, @@ -93,16 +99,18 @@ export default function AddEditWebhookForm({ ); const updateMutation = useMutation( - trpc.webhook.update.mutationOptions({ + orpc.webhook.update.mutationOptions({ + context: { orgId: organizationId }, onSuccess: () => { toast.success("Webhook updated"); queryClient.invalidateQueries({ - queryKey: trpc.webhook.list.queryKey({ organizationId }), + queryKey: orpc.webhook.listByOrg.queryKey({ + input: { organizationId }, + }), }); queryClient.invalidateQueries({ - queryKey: trpc.webhook.get.queryKey({ - organizationId, - webhookId: webhook!.id, + queryKey: orpc.webhook.get.queryKey({ + input: { webhookId: webhook!.id }, }), }); router.push(`/${organizationSlug}/webhooks/${webhook!.id}`); @@ -114,13 +122,13 @@ export default function AddEditWebhookForm({ ); const regenerateSecretMutation = useMutation( - trpc.webhook.regenerateSecret.mutationOptions({ + orpc.webhook.regenerateSecret.mutationOptions({ + context: { orgId: organizationId }, onSuccess: () => { toast.success("Secret regenerated"); queryClient.invalidateQueries({ - queryKey: trpc.webhook.get.queryKey({ - organizationId, - webhookId: webhook!.id, + queryKey: orpc.webhook.get.queryKey({ + input: { webhookId: webhook!.id }, }), }); }, @@ -139,14 +147,10 @@ export default function AddEditWebhookForm({ if (isEditing) { updateMutation.mutate({ ...payload, - organizationId, webhookId: webhook.id, }); } else { - createMutation.mutate({ - ...payload, - organizationId, - }); + createMutation.mutate(payload); } }; @@ -239,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/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..580398b0 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,16 @@ 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: { webhookId }, + context: { orgId: organization.id }, + select: (r) => r.data, }), ); @@ -58,12 +58,13 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { useSendTestWebhookModal({ webhook }); const deleteMutation = useMutation( - trpc.webhook.delete.mutationOptions({ + orpc.webhook.delete.mutationOptions({ + context: { orgId: organization.id }, onSuccess: () => { toast.success("Webhook deleted"); queryClient.invalidateQueries({ - queryKey: trpc.webhook.list.queryKey({ - organizationId: organization.id, + queryKey: orpc.webhook.listByOrg.queryKey({ + input: { organizationId: organization.id }, }), }); router.push(`/${organization.slug}/webhooks`); @@ -75,20 +76,20 @@ export default function WebhookHeader({ webhookId }: WebhookHeaderProps) { ); const toggleMutation = useMutation( - trpc.webhook.toggle.mutationOptions({ + orpc.webhook.update.mutationOptions({ + context: { orgId: organization.id }, 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: { webhookId }, }), }); queryClient.invalidateQueries({ - queryKey: trpc.webhook.list.queryKey({ - organizationId: organization.id, + queryKey: orpc.webhook.listByOrg.queryKey({ + input: { organizationId: organization.id }, }), }); }, @@ -125,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} /> @@ -193,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-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 d83edfb9..44e7be3c 100644 --- a/apps/web/src/hooks/use-upload.ts +++ b/apps/web/src/hooks/use-upload.ts @@ -1,7 +1,8 @@ import { useState } from "react"; -import { useTRPC } from "@/trpc/react"; +import { useOrganization } from "@/hooks/use-organization"; +import { orpc } from "@/lib/orpc"; +import { ORPCError } from "@orpc/client"; import { useMutation } from "@tanstack/react-query"; -import { TRPCClientError } from "@trpc/client"; import { toast } from "sonner"; const uploadWithProgress = ( @@ -43,9 +44,11 @@ const uploadWithProgress = ( }; export function useUploadFiles({ namespaceId }: { namespaceId: string }) { - const trpc = useTRPC(); + const organization = useOrganization(); const { mutateAsync: getPresignedUrls } = useMutation( - trpc.upload.getPresignedUrls.mutationOptions(), + orpc.upload.createBatch.mutationOptions({ + context: { orgId: organization.id }, + }), ); const [uploadedFiles, setUploadedFiles] = useState< @@ -69,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, { @@ -86,9 +90,7 @@ export function useUploadFiles({ namespaceId }: { namespaceId: string }) { ); } catch (error) { toast.error( - error instanceof TRPCClientError - ? error.message - : "Failed to upload file!", + error instanceof ORPCError ? error.message : "Failed to upload file!", ); } finally { setProgresses({}); diff --git a/apps/web/src/hooks/use-webhooks.ts b/apps/web/src/hooks/use-webhooks.ts index 76af3c51..984165fc 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.listByOrg.queryOptions({ + input: { organizationId }, + enabled: !!organizationId, + staleTime: 60000, // 1 minute + }), ); return { diff --git a/apps/web/src/lib/agentic/index.ts b/apps/web/src/lib/agentic/index.ts new file mode 100644 index 00000000..26311ae3 --- /dev/null +++ b/apps/web/src/lib/agentic/index.ts @@ -0,0 +1,197 @@ +import type { ModelMessage } from "ai"; +import { MyUIMessage } from "@/types/ai"; +import { + createUIMessageStream, + createUIMessageStreamResponse, + generateId, + generateText, + streamText, +} from "ai"; + +import type { + NamespaceLanguageModel, + QueryVectorStoreOptions, +} from "@agentset/engine"; + +import { NEW_MESSAGE_PROMPT } from "../prompts"; +import { agenticSearch } from "./search"; +import { formatSources } from "./utils"; + +type AgenticPipelineOptions = { + model: NamespaceLanguageModel; + queryOptions: Omit; + systemPrompt?: string; + temperature?: number; + messagesWithoutQuery: ModelMessage[]; + lastMessage: string; + afterQueries?: (totalQueries: number) => void; + maxEvals?: number; + tokenBudget?: number; +}; + +const agenticPipeline = ({ + model, + queryOptions, + headers, + systemPrompt, + temperature, + messagesWithoutQuery, + lastMessage, + afterQueries, + maxEvals = 2, + tokenBudget = 4096, + includeLogs = true, +}: AgenticPipelineOptions & { + headers?: HeadersInit; + afterQueries?: (totalQueries: number) => void; + includeLogs?: boolean; +}) => { + const messages: ModelMessage[] = [ + ...messagesWithoutQuery, + { role: "user", content: lastMessage }, + ]; + + const stream = createUIMessageStream({ + execute: async ({ writer }) => { + writer.write({ + type: "start", + messageId: generateId(), + }); + writer.write({ + type: "start-step", + }); + + writer.write({ + type: "data-status", + data: { value: "generating-queries" }, + }); + + // step 1. generate queries + const { chunks, queryToResult, totalQueries } = await agenticSearch({ + model, + messages, + queryOptions, + maxEvals, + tokenBudget, + onQueries: (newQueries) => { + writer.write({ + type: "data-status", + data: { + value: "searching", + queries: newQueries.map((q) => q.query), + }, + }); + }, + }); + + afterQueries?.(totalQueries); + + writer.write({ + type: "data-status", + data: { value: "generating-answer" }, + }); + + // TODO: shrink chunks and only select relevant ones to pass to the LLM + const dedupedData = Object.values(chunks); + writer.write({ + id: "SOURCES", + type: "data-agentset-sources", + data: { + results: dedupedData, + ...(includeLogs && { + logs: Object.values(queryToResult), + }), + }, + }); + + const newMessages: ModelMessage[] = [ + ...messagesWithoutQuery, + { + role: "user", + content: NEW_MESSAGE_PROMPT.compile({ + chunks: formatSources(dedupedData), + // put the original query in the message to help with context + query: `${lastMessage}`, + }), + }, + ]; + + const messageStream = streamText({ + model: model.model, + providerOptions: model.providerOptions, + system: systemPrompt, + messages: newMessages, + temperature, + }); + + writer.merge( + messageStream.toUIMessageStream({ + sendStart: false, + }), + ); + }, + onError(error) { + console.error(error); + return "An error occurred"; + }, + }); + + return createUIMessageStreamResponse({ stream, headers }); +}; + +export const generateAgenticResponse = async ({ + model, + queryOptions, + systemPrompt, + temperature, + messagesWithoutQuery, + lastMessage, + afterQueries, + maxEvals = 3, + tokenBudget = 4096, +}: AgenticPipelineOptions) => { + const messages: ModelMessage[] = [ + ...messagesWithoutQuery, + { role: "user", content: lastMessage }, + ]; + + // step 1. generate queries + const { chunks, totalQueries } = await agenticSearch({ + model, + messages, + queryOptions, + maxEvals, + tokenBudget, + }); + + afterQueries?.(totalQueries); + + // TODO: shrink chunks and only select relevant ones to pass to the LLM + const dedupedData = Object.values(chunks); + const newMessages: ModelMessage[] = [ + ...messagesWithoutQuery, + { + role: "user", + content: NEW_MESSAGE_PROMPT.compile({ + chunks: formatSources(dedupedData), + // put the original query in the message to help with context + query: `${lastMessage}`, + }), + }, + ]; + + const answer = await generateText({ + model: model.model, + providerOptions: model.providerOptions, + system: systemPrompt, + messages: newMessages, + temperature: temperature, + }); + + return { + answer: answer.text, + sources: dedupedData, + }; +}; + +export default agenticPipeline; diff --git a/apps/web/src/lib/api/errors.ts b/apps/web/src/lib/api/errors.ts index 45dfe764..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"; -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/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 92f6dc8d..c91201f0 100644 --- a/apps/web/src/lib/api/handler/namespace.ts +++ b/apps/web/src/lib/api/handler/namespace.ts @@ -1,35 +1,16 @@ 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; -} - -const getNamespace = async ({ +export const getNamespace = async ({ namespaceId, organizationId, }: { namespaceId: string; organizationId: string; }) => { - return unstable_cache( + const namespace = await unstable_cache( async () => { return await db.namespace.findUnique({ where: { @@ -45,59 +26,15 @@ const getNamespace = async ({ tags: [`org:${organizationId}`, `ns:${namespaceId}`], }, )(); -}; - -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.", - }); - } + if (!namespace) return 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 }, - ); + // 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/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/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..d8fa3952 --- /dev/null +++ b/apps/web/src/lib/chat/index.ts @@ -0,0 +1,311 @@ +import type { chatOptionsSchema } from "@/schemas/api/chat"; +import type { 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 { + NamespaceLanguageModel, + 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: NamespaceLanguageModel; + 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.model, + providerOptions: languageModel.providerOptions, + 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: NamespaceLanguageModel; + queryOptions: Awaited>["queryOptions"]; +}) => + new DeepResearchPipeline({ + modelConfig: { + json: languageModel.model, + planning: languageModel.model, + summary: languageModel.model, + answer: languageModel.model, + }, + 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.model, + providerOptions: languageModel.providerOptions, + 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.model, + providerOptions: languageModel.providerOptions, + 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/deep-research/classes.ts b/apps/web/src/lib/deep-research/classes.ts new file mode 100644 index 00000000..5a9311be --- /dev/null +++ b/apps/web/src/lib/deep-research/classes.ts @@ -0,0 +1,128 @@ +/** + * Data models for the Deep Research Cookbook + */ + +/** + * Structured representation of a research plan with search queries. + * Used to parse the LLM's planning output into a structured format + * that can be easily processed by the research pipeline. + */ +export interface ResearchPlan { + queries: string[]; +} + +/** + * Structured representation of filtered source indices. + * Used to parse the LLM's source evaluation output into a structured + * format that identifies which search results should be retained. + */ +export interface SourceList { + sources: number[]; +} + +/** + * Container for an individual search result with its metadata and content. + * Holds both the original content and the filtered/processed content + * that's relevant to the research topic. + */ +export class SearchResult { + id: string; + metadata?: Record; + content: string; + + constructor(params: { + id: string; + metadata?: Record; + content: string; + }) { + this.id = params.id; + this.metadata = params.metadata; + this.content = params.content; + } + + /** + * (For Report Generation and Completeness Evaluation) String representation with title, link and refined content. + */ + toString(): string { + return `ID: ${this.id}\nMetadata: ${JSON.stringify(this.metadata)}\nContent: ${this.content.substring(0, 1000)}`; + } + + /** + * (For Filtering ONLY) Abbreviated string representation with truncated raw content. + */ + shortStr(): string { + return `ID: ${this.id}\nMetadata: ${JSON.stringify(this.metadata)}\nContent: ${this.content.substring(0, 1000)}`; + } +} + +/** + * Collection of search results with utilities for manipulation and display. + * Provides methods for combining result sets, deduplication, and + * different string representations for processing and display. + */ +export class SearchResults { + results: SearchResult[]; + + constructor(results: SearchResult[]) { + this.results = results; + } + + /** + * Detailed string representation of all search results with indices. + */ + toString(): string { + return this.results + .map((result, i) => `[${i + 1}] ${result.toString()}`) + .join("\n\n"); + } + + /** + * Combine two SearchResults objects by concatenating their result lists. + */ + add(other: SearchResults): SearchResults { + return new SearchResults([...this.results, ...other.results]); + } + + /** + * Abbreviated string representation of all search results with indices. + */ + shortStr(): string { + return this.results + .map((result, i) => `[${i + 1}] ${result.shortStr()}`) + .join("\n\n"); + } + + /** + * Remove duplicate search results based on ID. + * Returns a new SearchResults object with unique entries. + */ + dedup(): SearchResults { + const seenIds = new Set(); + const uniqueResults: SearchResult[] = []; + + for (const result of this.results) { + if (!seenIds.has(result.id)) { + seenIds.add(result.id); + uniqueResults.push(result); + } + } + + return new SearchResults(uniqueResults); + } +} + +/** + * Return type for iterative research results containing final search results and used queries. + */ +export interface IterativeResearchResult { + finalSearchResults: SearchResults; + queriesUsed: string[]; +} + +/** + * Return type for filtered results containing filtered search results and source indices. + */ +export interface FilteredResultsData { + filteredResults: SearchResults; + sourceIndices: number[]; +} diff --git a/apps/web/src/lib/deep-research/config.ts b/apps/web/src/lib/deep-research/config.ts new file mode 100644 index 00000000..a1b31842 --- /dev/null +++ b/apps/web/src/lib/deep-research/config.ts @@ -0,0 +1,271 @@ +/** + * Configuration for the Deep Research Cookbook + */ + +/** + * Research Configuration Parameters + * + * These parameters control the Deep Research process, allowing customization + * of research behavior, model selection, and output format. + */ + +// Resource Allocation +// Parameters controlling research depth and breadth +export const RESEARCH_CONFIG = { + budget: 2, // Number of research refinement cycles to perform (in addition to the initial search operation) + maxQueries: 2, // Maximum number of search queries per research cycle + maxSources: 5, // Maximum number of sources to include in final synthesis + maxTokens: 8192, // Maximum number of tokens in the generated report +}; + +/** + * Core prompt function that adds current date information to all prompts + * This ensures all models have the correct temporal context for research + */ +export const getCurrentDateContext = () => { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; // JavaScript months are 0-indexed + const day = now.getDate(); + const monthName = now.toLocaleString("default", { month: "long" }); + + return `Current date is ${year}-${month.toString().padStart(2, "0")}-${day + .toString() + .padStart(2, "0")} (${monthName} ${day}, ${year}). +When searching for recent information, prioritize results from the current year (${year}) and month (${monthName} ${year}). +For queries about recent developments, include the current year (${year}) in your search terms. +When ranking search results, consider recency as a factor - newer information is generally more relevant for current topics.`; +}; + +// System Prompts +// Instructions for each stage of the research process +export const PROMPTS = { + // Planning: Generates initial research queries + planningPrompt: `${getCurrentDateContext()} +You are a strategic research planner with expertise in breaking down complex questions into logical search steps. When given a research topic or question, you'll analyze what specific information is needed and develop a sequential research plan. + + First, identify the core components of the question and any implicit information needs. + + Then provide a numbered list of 3-5 sequential search queries + + Your queries should be: + - Specific and focused (avoid broad queries that return general information) + - Written in natural language without Boolean operators (no AND/OR) + - Designed to progress logically from foundational to specific information + + It's perfectly acceptable to start with exploratory queries to "test the waters" before diving deeper. Initial queries can help establish baseline information or verify assumptions before proceeding to more targeted searches.`, + + // Plan Parsing: Extracts structured data from planning output + planParsingPrompt: `${getCurrentDateContext()} + You are a research assistant, you will be provided with a plan of action to research a topic, identify the queries that we should run to search for the topic. Look carefully + at the general plan provided and identify the key queries that we should run. For dependent queries (those requiring results from earlier searches), leave them for later execution and focus only on the self-contained queries that can be run immediately.`, + + // Content Processing: Identifies relevant information from search results + rawContentSummarizerPrompt: `${getCurrentDateContext()} + You are a research extraction specialist. Given a research topic and raw web content, create a thoroughly detailed synthesis as a cohesive narrative that flows naturally between key concepts. + + Extract the most valuable information related to the research topic, including relevant facts, statistics, methodologies, claims, and contextual information. Preserve technical terminology and domain-specific language from the source material. + + Structure your synthesis as a coherent document with natural transitions between ideas. Begin with an introduction that captures the core thesis and purpose of the source material. Develop the narrative by weaving together key findings and their supporting details, ensuring each concept flows logically to the next. + + Integrate specific metrics, dates, and quantitative information within their proper context. Explore how concepts interconnect within the source material, highlighting meaningful relationships between ideas. Acknowledge limitations by noting where information related to aspects of the research topic may be missing or incomplete. + + Important guidelines: + - Maintain original data context (e.g., "2024 study of 150 patients" rather than generic "recent study") + - Preserve the integrity of information by keeping details anchored to their original context + - Create a cohesive narrative rather than disconnected bullet points or lists + - Use paragraph breaks only when transitioning between major themes + + Critical Reminder: If content lacks a specific aspect of the research topic, clearly state that in the synthesis, and you should NEVER make up information and NEVER rely on external knowledge.`, + + // Completeness Evaluation: Determines if more research is needed + evaluationPrompt: `${getCurrentDateContext()} +You are a research query optimizer. Your task is to analyze search results against the original research goal and generate follow-up queries to fill in missing information. + + PROCESS: + 1. Identify ALL information explicitly requested in the original research goal + 2. Analyze what specific information has been successfully retrieved in the search results + 3. Identify ALL information gaps between what was requested and what was found + 4. For entity-specific gaps: Create targeted queries for each missing attribute of identified entities + 5. For general knowledge gaps: Create focused queries to find the missing conceptual information + + QUERY GENERATION RULES: + - IF specific entities were identified AND specific attributes are missing: + * Create direct queries for each entity-attribute pair (e.g., "LeBron James height") + - IF general knowledge gaps exist: + * Create focused queries to address each conceptual gap (e.g., "criteria for ranking basketball players") + - Queries must be constructed to directly retrieve EXACTLY the missing information + - Avoid tangential or merely interesting information not required by the original goal + - Prioritize queries that will yield the most critical missing information first + + OUTPUT FORMAT: + First, briefly state: + 1. What specific information was found + 2. What specific information is still missing + 3. What type of knowledge gaps exist (entity-specific or general knowledge) + + Then provide up to 5 targeted queries that directly address the identified gaps, ordered by importance. Please consider that you + need to generate queries that tackle a single goal at a time (searching for A AND B will return bad results). Be specific!`, + + // Evaluation Parsing: Extracts structured data from evaluation output + evaluationParsingPrompt: `${getCurrentDateContext()} + You are a research assistant, you will be provided with a some reasoning and a list of queries, and you will need to parse the list into a list of queries. +`, + + // Source Filtering: Selects most relevant sources + filterPrompt: `${getCurrentDateContext()} +You are a web-search filter assistant. Your task is to filter and rank search results based on the research topic, to help your colleague create a comprehensive, in-depth, and detailed research report. + + You will be given the research topic, and the current search results: their titles, links, and contents. Your goal is to: + 1. Rank ALL results that have ANY relevance to the topic, even if the connection is indirect + 2. Use the following relevance categories: + - High relevance: Directly addresses the main topic + - Medium relevance: Contains useful supporting information or related concepts + - Low relevance: Has tangential or contextual information that might be valuable for background or broader perspective + - No relevance: Completely unrelated or irrelevant (only these should be excluded) + + Remember: + - Keep sources that might provide valuable context or supporting information, even if not directly focused on the main topic + - Sources with partial relevance should be ranked lower rather than excluded + - Consider how each source might contribute to different aspects of the research report (background, context, examples, etc.) + + At the end of your response, return a LIST of source numbers in order of relevance, including ALL sources that have any potential value (high, medium, or low relevance). Only exclude sources that are completely irrelevant to the topic.`, + + // Source Filtering: Selects most relevant sources + sourceParsingPrompt: `${getCurrentDateContext()} + You are a research assistant, you will be provided with a relevance analysis of the search results. + + You need to return a list of source numbers corresponding to the search results, in the order of relevance to the research topic.`, + + // Answer Generation: Creates final research report + answerPrompt: `${getCurrentDateContext()} + You are a senior research analyst tasked with creating a professional, publication-ready report. + Using ONLY the provided sources, produce a markdown document (at least 5 pages) following these exact requirements: + + # Structure Guidelines + + 1. **Abstract** + - Provide a concise (250-300 words) summary of the entire research + - State the main research question/objective + - Highlight key findings and their significance + - Summarize major conclusions and implications + - Write in a self-contained manner that can stand alone + 2. **Introduction** + - Contextualize the research topic + - State the report's scope and objectives + - Preview key themes + 3. **Analysis** + - Group findings into thematic categories + - Compare/contrast different sources' perspectives + - Highlight patterns, contradictions, and evidence quality + - MUST include numbered citations [1][2]... to support all key claims and analysis. Never make factual statements without providing the corresponding citation. Format citations as [n] directly after the relevant text. + 4. **Conclusion** + - Synthesize overarching insights + - Discuss practical implications + - Identify knowledge gaps and research limitations + - Suggest areas for further investigation + 5. **References** + - MUST be included in the report to improve the readability and credibility. + - Include ALL sources in the references section, even those not directly cited in the report + - Number references consecutively (1, 2, 3...) without gaps + + # Composition Rules + * Strict source adherence: Every claim must cite sources using [n] notation + * Analytical depth: Prioritize insight generation over mere information listing + * Objective framing: Present conflicting evidence without bias + * Information hierarchy: Use H2 headers for main sections, H3 for subsections + * Visual clarity: Format tables with | delimiters and alignment markers + * Citation integrity: Include numbered references with full source metadata + + # Prohibitions + * Bullet points/listicles + * Unsupported assertions + * Informal language + * Repetitive content + * Source aggregation without analysis + * External knowledge beyond provided sources + + # Formatting Requirements + + [Research Topic] + + ## Abstract + [Abstract content...] + + ## Introduction + [Cohesive opening paragraph...] + [More details about the research topic...] + [General overview of the report...] + + ## [Primary Theme] + [Detailed analysis with integrated citations [1][3]. Compare multiple sources...] + [Additional details)] + + ### [Subtheme] + [Specific insights...] + + ### [Subtheme Where Table or Chart is Helpful] + + [Table Analysis in full paragraphs, avoid bullet points...] + + *Table X: Caption...[citation] (MUST be put above the table and seperated by a blank line)* + + | Comparison Aspect | Source A [2] | Source B [4] | + |--------------------|--------------|--------------| + | Key metric | xx% | xx% | + + + [Chart Analysis in full paragraphs, avoid bullet points...] + \`\`\`mermaid + %% Choose one: flowchart, sequenceDiagram, classDiagram, stateDiagram, gantt, pie, xychart-beta + %% DO NOT PUT TITLE in MERMAID CODE! titles should be put in THE FIGURE CAPTION + %% To reduce the rendering difficulty, avoid multiple series, stacked charts, or complex features. + %% DATA ARRAYS and AXIS RANGES MUST CONTAIN NUMBERS ONLY [10, 20, 30], e.g. for units like heights, use inches (74) instead of feet inches (6'2") + %% NEVER include values that are null, n/a, or undefined in the data series. + [CHART_TYPE] + %% For xy/bar charts: + xlabel "[X_AXIS_LABEL]" + ylabel "[Y_AXIS_LABEL]" + + %% For data series, use one of these formats: + %% Format 1 - Simple bar/line: + "[LABEL1]" [VALUE1] + "[LABEL2]" [VALUE2] + + %% Format 2 - Array style (xychart-beta): + %% For measurements with special units (feet/inches, degrees°, minutes', arc-seconds''), you MUST use double single-quotes ('') to escape, e.g., ["6'2''", "45°2''", "23'45''"] NOT ["6'2\"", "45°2\""] + xychart-beta + x-axis "[X_AXIS_LABEL]" ["Label1", "Label2", "Label3"] + y-axis "[Y_AXIS_LABEL]" MIN_VALUE --> MAX_VALUE + bar [value1, value2, value3] + \`\`\` + *Figure X: Caption...[citation] (MUST be put below the figure and seperated by a blank line)* + + ## Conclusion + [Synthesized takeaways...] [5][6] + [Explicit limitations discussion...] + [Overall summary with 5/6 paragraphs] + + ### References + 1. [Title of Source](https://url-of-source) + 2. [Complete Source Title](https://example.com/full-url) + + # Reference Rules + * Number all citations consecutively: [1], [2], [3], etc. + * Include ALL sources in the reference list, whether cited in the report or not + * No gaps allowed in the reference numbering + * Format each reference as: [Title](URL) + * For consecutive citations in text, use ranges: [1-3] instead of [1][2][3] + + # Example + If your research report mentioned sources 1, 3, list ALL of them in references including 2 to avoid gaps: + 1. [First Source](https://example.com/first) + 2. [Second Source](https://example.com/second) + 3. [Third Source](https://example.com/third) + + Begin by analyzing source relationships before writing. Verify all citations match reference numbers. Maintain academic tone throughout. + While you think, consider that the sections you need to write should be 3/4 paragraphs each. We do not want to end up with a list of bullet points. Or very short sections. + Think like a writer, you are optimizing coherence and readability. + In terms of content is like you are writing the chapter of a book, with a few headings and lots of paragraphs. Plan to write at least 3 paragraphs for each heading you want to + include in the report.`, +}; diff --git a/apps/web/src/lib/deep-research/index.ts b/apps/web/src/lib/deep-research/index.ts new file mode 100644 index 00000000..4b0982b2 --- /dev/null +++ b/apps/web/src/lib/deep-research/index.ts @@ -0,0 +1,543 @@ +/** + * Deep Research Pipeline Implementation + */ + +import type { LanguageModel } from "ai"; +import { + extractReasoningMiddleware, + generateObject, + generateText, + streamText, + wrapLanguageModel, +} from "ai"; +import { z } from "zod/v4"; + +import type { Namespace } from "@agentset/db"; +import type { QueryVectorStoreOptions } from "@agentset/engine"; +import { queryVectorStore } from "@agentset/engine"; + +import type { FilteredResultsData, IterativeResearchResult } from "./classes"; +import { SearchResult, SearchResults } from "./classes"; +import { PROMPTS, RESEARCH_CONFIG } from "./config"; + +type ModelConfig = { + planning: LanguageModel; + json: LanguageModel; + summary: LanguageModel; + answer: LanguageModel; +}; + +/** + * Deep Research Pipeline + * + * This class implements the complete research pipeline, from query generation + * to final report synthesis. + */ +export class DeepResearchPipeline { + private queryOptions: Omit; + + private modelConfig: ModelConfig; + private researchConfig: typeof RESEARCH_CONFIG; + private prompts: typeof PROMPTS; + private currentSpending: number = 0; + + private researchPlanSchema = z.object({ + queries: z + .string() + .array() + .describe("A list of search queries to thoroughly research the topic"), + }); + + private sourceListSchema = z.object({ + sources: z.array(z.number()).describe("List of source indices to keep"), + }); + + constructor({ + researchConfig = RESEARCH_CONFIG, + prompts = PROMPTS, + modelConfig, + queryOptions, + ...options + }: { + modelConfig: ModelConfig; + queryOptions: Omit; + researchConfig?: typeof RESEARCH_CONFIG; + prompts?: typeof PROMPTS; + maxQueries?: number; + maxSources?: number; + maxCompletionTokens?: number; + }) { + this.modelConfig = modelConfig; + this.queryOptions = queryOptions; + this.researchConfig = researchConfig; + this.prompts = prompts; + + // Override config with options + if (options.maxQueries !== undefined) { + this.researchConfig.maxQueries = options.maxQueries; + } + if (options.maxSources !== undefined) { + this.researchConfig.maxSources = options.maxSources; + } + if (options.maxCompletionTokens !== undefined) { + this.researchConfig.maxTokens = options.maxCompletionTokens; + } + } + + /** + * Generate initial research queries based on the topic + * + * @param topic The research topic + * @returns List of search queries + */ + private async generateInitialQueries({ + topic, + }: { + topic: string; + }): Promise { + let allQueries = await this.generateResearchQueries(topic); + + if (this.researchConfig.maxQueries > 0) { + allQueries = allQueries.slice(0, this.researchConfig.maxQueries); + } + + console.log(`\n\n\x1b[36m🔍 Initial queries: ${allQueries}\x1b[0m`); + + if (allQueries.length === 0) { + console.error("ERROR: No initial queries generated"); + return []; + } + + return allQueries; + } + + /** + * Generate research queries for a given topic using LLM + * + * @param topic The research topic + * @returns List of search queries + */ + private async generateResearchQueries(topic: string): Promise { + const { object } = await generateObject({ + model: this.modelConfig.json, + schema: this.researchPlanSchema, + system: this.prompts.planningPrompt, + prompt: `Research Topic: ${topic}`, + }); + + console.log( + `\x1b[35m📋 Research queries generated: \n - ${object.queries.join( + "\n - ", + )}\x1b[0m`, + ); + + return object.queries; + } + + /** + * Perform a single web search + */ + private async webSearch(query: string): Promise { + console.log(`\x1b[34m🔎 Perform web search with query: ${query}\x1b[0m`); + + // Truncate long queries to avoid issues (like in the Python version) + if (query.length > 400) { + query = query.substring(0, 400); + console.log( + `\x1b[33m⚠️ Truncated query to 400 characters: ${query}\x1b[0m`, + ); + } + + const searchResults = await queryVectorStore({ + query, + ...this.queryOptions, + }); + const results = (searchResults?.results ?? []).map((result) => { + return new SearchResult({ + id: result.id, + metadata: result.metadata, + content: result.text, + }); + }); + + console.log( + `\x1b[32m📊 Web Search Responded with ${results.length} results\x1b[0m`, + ); + + // Process and summarize raw content if available + const processedResults = await this.processSearchResultsWithSummarization( + query, + results, + ); + + return new SearchResults(processedResults); + } + + /** + * Process search results with content summarization + * + * @param query The search query + * @param results The search results to process + * @returns Processed search results with summarized content + */ + private async processSearchResultsWithSummarization( + query: string, + results: SearchResult[], + ): Promise { + // Create tasks for summarization + const summarizationTasks = []; + const resultInfo = []; + + for (const result of results) { + if (!result.content) { + continue; + } + + // Create a task for summarization + const task = this._summarize_content_async({ + result, + query, + }); + + summarizationTasks.push(task); + resultInfo.push(result); + } + + // Wait for all summarization tasks to complete + const summarizedContents = await Promise.all(summarizationTasks); + + // Combine results with summarized content + const formattedResults: SearchResult[] = []; + for (let i = 0; i < resultInfo.length; i++) { + const result = resultInfo[i]!; + const summarizedContent = summarizedContents[i]!; + + formattedResults.push( + new SearchResult({ + id: result.id, + metadata: result.metadata, + content: summarizedContent, + }), + ); + } + + return formattedResults; + } + + /** + * Summarize content asynchronously using the LLM + * + * @param props The props object containing searchResult and query + * @returns The summarized content + */ + private async _summarize_content_async(props: { + result: SearchResult; + query: string; + }): Promise { + console.log( + `\x1b[36m📝 Summarizing content from ID: ${props.result.id}\x1b[0m`, + ); + + const result = await generateText({ + model: this.modelConfig.summary, + system: this.prompts.rawContentSummarizerPrompt, + prompt: `${props.result.content}\n\n${props.query}`, + }); + + return result.text; + } + + /** + * Execute searches for all queries in parallel + * + * @param queries List of search queries + * @returns Combined search results + */ + private async performSearch({ + queries, + }: { + queries: string[]; + }): Promise { + const tasks = queries.map(async (query) => { + // Perform search + const results = await this.webSearch(query); + return results; + }); + + const resultsList = await Promise.all(tasks); + + let combinedResults = new SearchResults([]); + for (const results of resultsList) { + combinedResults = combinedResults.add(results); + } + + const combinedResultsDedup = combinedResults.dedup(); + console.log( + `Search complete, found ${combinedResultsDedup.results.length} results after deduplication`, + ); + + return combinedResultsDedup; + } + + /** + * Evaluate if the current search results are sufficient or if more research is needed + * + * @param topic The research topic + * @param results Current search results + * @param queries List of queries already used + * @returns List of additional queries needed or empty list if research is complete + */ + private async evaluateResearchCompleteness( + topic: string, + results: SearchResults, + queries: string[], + ): Promise { + const formattedResults = results.toString(); + + // context length issue here! + + const evaluation = await generateText({ + model: this.modelConfig.planning, + system: this.prompts.evaluationPrompt, + prompt: + `${topic}\n\n` + + `${queries}\n\n` + + `${formattedResults}`, + }); + + // console.log( + // "\x1b[43m🔄 ================================================\x1b[0m\n\n" + // ); + // console.log(`\x1b[36m📝 Evaluation:\n\n ${evaluation.text}\x1b[0m`); + + const { object } = await generateObject({ + model: this.modelConfig.json, + system: this.prompts.evaluationParsingPrompt, + prompt: `Evaluation to be parsed: ${evaluation.text}`, + schema: this.researchPlanSchema, + }); + + return object.queries; + } + + /** + * Process search results by deduplicating and filtering + * + * @param topic The research topic + * @param results Search results to process + * @returns Filtered search results + */ + private async processSearchResults({ + topic, + results, + }: { + topic: string; + results: SearchResults; + }): Promise { + // Deduplicate results + results = results.dedup(); + console.log( + `Search complete, found ${results.results.length} results after deduplication`, + ); + + return results; + } + + /** + * Filter search results based on relevance to the topic + * + * @param topic The research topic + * @param results Search results to filter + * @returns Tuple of (filtered results, source list) + */ + private async filterResults({ + topic, + results, + }: { + topic: string; + results: SearchResults; + }): Promise { + const formattedResults = results.toString(); + + const filterResponse = await generateText({ + model: this.modelConfig.planning, + system: this.prompts.filterPrompt, + prompt: `${topic}\n\n${formattedResults}`, + }); + + // console.log(`\x1b[36m📝 Filter response: ${filterResponse.text}\x1b[0m`); + + const { object } = await generateObject({ + model: this.modelConfig.json, + system: this.prompts.sourceParsingPrompt, + prompt: `Filter response to be parsed: ${filterResponse.text}`, + schema: this.sourceListSchema, + }); + + const sources = object.sources; + console.log(`\x1b[36m📊 Filtered sources: ${sources}\x1b[0m`); + + // Limit sources if needed + let limitedSources = sources; + if (this.researchConfig.maxSources > 0) { + limitedSources = sources.slice(0, this.researchConfig.maxSources); + } + + // Filter the results based on the source list + const filteredResults = new SearchResults( + limitedSources + .filter((i) => i > 0 && i <= results.results.length) + .map((i) => results.results[i - 1]) as SearchResult[], + ); + + return { + filteredResults, + sourceIndices: limitedSources, + }; + } + + /** + * Conduct iterative research within budget to refine results + * + * @param topic The research topic + * @param initialResults Results from initial search + * @param allQueries List of all queries used so far + * @returns Tuple of (final results, all queries used) + */ + private async conductIterativeResearch({ + topic, + initialResults, + allQueries, + }: { + topic: string; + initialResults: SearchResults; + allQueries: string[]; + }): Promise { + let results = initialResults; + + for (let i = 0; i < this.researchConfig.budget; i++) { + // Evaluate if more research is needed + const additionalQueries = await this.evaluateResearchCompleteness( + topic, + results, + allQueries, + ); + + // Exit if research is complete + if (additionalQueries.length === 0) { + console.log("\x1b[33m✅ No need for additional research\x1b[0m"); + break; + } + + // Limit the number of queries if needed + let queriesToUse = additionalQueries; + if (this.researchConfig.maxQueries > 0) { + queriesToUse = additionalQueries.slice( + 0, + this.researchConfig.maxQueries, + ); + } + + // console.log( + // "\x1b[43m🔄 ================================================\x1b[0m\n\n" + // ); + console.log( + `\x1b[36m📋 Additional queries from evaluation parser: ${queriesToUse}\n\n\x1b[0m`, + ); + // console.log( + // "\x1b[43m🔄 ================================================\x1b[0m\n\n" + // ); + + // Expand research with new queries + const newResults = await this.performSearch({ queries: queriesToUse }); + results = results.add(newResults); + allQueries.push(...queriesToUse); + } + + return { + finalSearchResults: results, + queriesUsed: allQueries, + }; + } + + /** + * Run the complete research pipeline + * + * @param topic The research topic + * @returns The research answer + */ + async runResearch(topic: string) { + console.log(`\x1b[36m🔍 Researching topic: ${topic}\x1b[0m`); + + // Step 1: Generate initial queries + const initialQueries = await this.generateInitialQueries({ topic }); + + // Step 2: Perform initial search + const initialResults = await this.performSearch({ + queries: initialQueries, + }); + + // Step 3: Conduct iterative research + const { finalSearchResults, queriesUsed } = + await this.conductIterativeResearch({ + topic, + initialResults, + allQueries: initialQueries, + }); + + // Step 4: Process search results + const processedResults = await this.processSearchResults({ + topic, + results: finalSearchResults, + }); + + // Step 4.5: Filter results based on relevance + const { filteredResults, sourceIndices } = await this.filterResults({ + topic, + results: processedResults, + }); + + console.log( + `\x1b[32m📊 Filtered results: ${filteredResults.results.length} sources kept\x1b[0m`, + ); + + // Step 5: Generate research answer with feedback loop + const answer = this.generateResearchAnswer({ + topic, + results: filteredResults, + }); + + return answer; + } + + /** + * Generate a comprehensive answer to the research topic based on the search results + * + * @param topic The research topic + * @param results Filtered search results to use for answer generation + * @returns Detailed research answer as a string + */ + private generateResearchAnswer({ + topic, + results, + }: { + topic: string; + results: SearchResults; + }) { + const formattedResults = results.toString(); + + const enhancedModel = wrapLanguageModel({ + model: this.modelConfig.answer as Exclude, + middleware: extractReasoningMiddleware({ tagName: "think" }), + }); + + const answer = streamText({ + model: enhancedModel, + system: this.prompts.answerPrompt, + prompt: `Research Topic: ${topic}\n\nSearch Results:\n${formattedResults}`, + maxOutputTokens: this.researchConfig.maxTokens, + }); + + return answer; + } +} 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..2b80a703 --- /dev/null +++ b/apps/web/src/lib/mcp/index.ts @@ -0,0 +1,204 @@ +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 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. 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_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_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 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; + + 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) => { + 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/orpc.ts b/apps/web/src/lib/orpc.ts new file mode 100644 index 00000000..1eb872e7 --- /dev/null +++ b/apps/web/src/lib/orpc.ts @@ -0,0 +1,50 @@ +import type { AppRouter } from "@/server/orpc/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"; + +/** + * 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: {} }], + }), + ], +}); + +/** 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/lib/prompts.ts b/apps/web/src/lib/prompts.ts index 5340e848..312d165c 100644 --- a/apps/web/src/lib/prompts.ts +++ b/apps/web/src/lib/prompts.ts @@ -17,3 +17,38 @@ Follow these STRICT guidelines: If the search results are completely irrelevant or insufficient to address any part of the query, respond: "I cannot answer this question as the search results do not contain relevant information about [specific topic]." `; + +export const NEW_MESSAGE_PROMPT = prmpt` +Most relevant search results: +${"chunks"} + +User's query: +${"query"} +`; + +export const CONDENSE_SYSTEM_PROMPT = prmpt` +Given a conversation history between Human and Assistant and a follow-up question from Human, rewrite the question into a standalone query that: + +1. Incorporates all relevant context from the prior conversation +2. Preserves specific details, names, and technical terms mentioned earlier +3. Maintains the original language and tone of the user's question +4. Focuses on searchable keywords and concepts to optimize vector database retrieval +5. Removes conversational elements like "as mentioned before" or "following up on" +6. Expands pronouns and references to their full form (e.g. "it" → "the database schema") + +Your task is to create a clear, context-rich query that will yield the most relevant search results from the vector database. + + +Question: ${"question"} + +History: +${"chatHistory"} +`; + +export const CONDENSE_USER_PROMPT = prmpt` +Chat History: +${"chatHistory"} + +Follow Up Message: +${"query"} +`; 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/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/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/delete-hosting.ts b/apps/web/src/openapi/v1/hosting/delete-hosting.ts deleted file mode 100644 index 3653d5cf..00000000 --- a/apps/web/src/openapi/v1/hosting/delete-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 deleteHosting: ZodOpenApiOperationObject = { - operationId: "deleteHosting", - "x-speakeasy-name-override": "delete", - summary: "Delete hosting configuration", - description: "Delete the hosting configuration for a namespace.", - parameters: [namespaceIdPathSchema], - responses: { - "204": { - description: "The deleted hosting configuration", - content: { - "application/json": { - schema: successSchema(HostingSchema), - }, - }, - }, - ...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 e05f088b..00000000 --- a/apps/web/src/openapi/v1/hosting/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { deleteHosting } from "./delete-hosting"; -import { enableHosting } from "./enable-hosting"; -import { getHosting } from "./get-hosting"; -import { updateHosting } from "./update-hosting"; - -export const hostingPaths: ZodOpenApiPathsObject = { - "/v1/namespace/{namespaceId}/hosting": { - get: getHosting, - post: enableHosting, - patch: updateHosting, - delete: deleteHosting, - }, -}; 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 d69b9c56..00000000 --- a/apps/web/src/openapi/v1/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ZodOpenApiPathsObject } from "zod-openapi"; - -import { documentsPaths } from "./documents"; -import { hostingPaths } from "./hosting"; -import { ingestJobsPaths } from "./ingest-jobs"; -import { namespacesPaths } from "./namespaces"; -import { searchPaths } from "./search"; -import { uploadsPaths } from "./uploads"; -import { warmUpPaths } from "./warm-up"; - -export const v1Paths: ZodOpenApiPathsObject = { - ...namespacesPaths, - ...ingestJobsPaths, - ...documentsPaths, - ...searchPaths, - ...uploadsPaths, - ...hostingPaths, - ...warmUpPaths, -}; 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 8fab0d23..00000000 --- a/apps/web/src/openapi/v1/namespaces/delete-namespace.ts +++ /dev/null @@ -1,36 +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 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 deleted namespace", - content: { - "application/json": { - schema: successSchema(NamespaceSchema), - }, - }, - }, - ...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/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 04afbe15..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. Files are limited to 5 MB on the Free plan and 200 MB on paid plans.", - 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 f08788b1..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. Files are limited to 5 MB on the Free plan and 200 MB on paid plans.", - 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/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 7cefcbeb..5ceac458 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() @@ -124,6 +142,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/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/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/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 1b04ee6d..00000000 --- a/apps/web/src/server/api/routers/api-keys.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { revalidateTag } from "next/cache"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { createApiKey, createApiKeySchema } from "@/services/api-key/create"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -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 ctx.db.organizationApiKey.findMany({ - where: { - organizationId: input.orgId, - }, - omit: { - key: true, - }, - }); - - 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" }); - } - - const apiKey = await ctx.db.organizationApiKey.delete({ - where: { - id: input.id, - }, - }); - - revalidateTag(`apiKey:${apiKey.key}`, "max"); - - 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 ee92c696..00000000 --- a/apps/web/src/server/api/routers/billing.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { getBaseUrl } from "@/lib/utils"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; - -import { stripe } from "@agentset/stripe"; -import { - getStripeEnvironment, - 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!; - }), - 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 8ea201ee..00000000 --- a/apps/web/src/server/api/routers/domains.ts +++ /dev/null @@ -1,238 +0,0 @@ -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 { 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"; - -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; -}; - -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, - }, - }); - }), - 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 }, - }; - }), - 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, - }, - }); - }), -}); 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 60e79a2a..00000000 --- a/apps/web/src/server/api/routers/ingest-jobs.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { emitIngestJobWebhook } from "@/lib/webhook/emit"; -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 { 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({ - 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" }); - } - - // 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, - namespaceId: namespace.id, - plan: organization.plan, - }); - }), - 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" }); - } - - 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, - organizationId: namespace.organizationId, - }); - - return updatedIngestJob; - }), - 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" }); - } - - 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, - }, - }); - - // 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 deleted file mode 100644 index feebd3d1..00000000 --- a/apps/web/src/server/api/routers/namespaces.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { ProtectedProcedureContext } from "@/server/api/trpc"; -import { createNamespaceSchema } from "@/schemas/api/namespace"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { deleteNamespace } from "@/services/namespaces/delete"; -import { - validateEmbeddingModel, - validateVectorStoreConfig, -} from "@/services/namespaces/validate"; -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 }) => { - 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 } }, - }), - ]); - - return namespace; - }), - 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 daa96e34..00000000 --- a/apps/web/src/server/api/routers/organizations.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { deleteOrganization } from "@/services/organizations/delete"; -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 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", - }, - }, - }, - }); - - 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 3c9e237a..00000000 --- a/apps/web/src/server/api/routers/uploads.ts +++ /dev/null @@ -1,106 +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 organization = await ctx.db.organization.findUnique({ - where: { id: ns.organizationId }, - select: { plan: true }, - }); - - if (!organization) { - throw new TRPCError({ - code: "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 TRPCError({ - code: - result.code === "file_too_large" - ? "FORBIDDEN" - : "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 organization = await ctx.db.organization.findUnique({ - where: { id: ns.organizationId }, - select: { plan: true }, - }); - - if (!organization) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Organization not found", - }); - } - - const result = await createBatchUpload({ - namespaceId: ns.id, - plan: organization.plan, - files: input.files, - }); - - if (!result.success) { - throw new TRPCError({ - code: - result.code === "file_too_large" - ? "FORBIDDEN" - : "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 a20ce9c7..00000000 --- a/apps/web/src/server/api/routers/webhooks.ts +++ /dev/null @@ -1,482 +0,0 @@ -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 { 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, - updateWebhookSchema, - WEBHOOK_EVENT_ID_PREFIX, - 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 - 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" }); - } - - const webhooks = await getWebhooks({ - organizationId: input.organizationId, - }); - - return webhooks.map(transformWebhook); - }), - - // 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 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, - }, - }, - }, - }); - - if (!webhook) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - - return { - ...transformWebhook(webhook), - consecutiveFailures: webhook.consecutiveFailures, - lastFailedAt: webhook.lastFailedAt, - createdAt: webhook.createdAt, - }; - }), - - // 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 - requireProPlan(member.organization.plan); - - await validateWebhook({ - input, - organizationId: input.organizationId, - }); - - const webhook = await createWebhook({ - name: input.name, - url: input.url, - secret: input.secret, - triggers: input.triggers, - namespaceIds: input.namespaceIds, - organizationId: input.organizationId, - }); - - return transformWebhook(webhook); - }), - - // 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" }); - } - - requireProPlan(member.organization.plan); - - const existingWebhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - }); - - if (!existingWebhook) { - 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); - }), - - // 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 ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - }); - - 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 }; - }), - - // 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 webhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - select: { disabledAt: true }, - }); - - if (!webhook) { - 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 }; - }), - - // 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 as WebhookTrigger], - }); - - 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 webhook = await ctx.db.webhook.findUnique({ - where: { - id: input.webhookId, - organizationId: input.organizationId, - }, - }); - - if (!webhook) { - 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 }; - }), - - // 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 dee58634..00000000 --- a/apps/web/src/server/api/trpc.ts +++ /dev/null @@ -1,181 +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(({ 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/base.ts b/apps/web/src/server/orpc/base.ts new file mode 100644 index 00000000..67ab429b --- /dev/null +++ b/apps/web/src/server/orpc/base.ts @@ -0,0 +1,468 @@ +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 { 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"; + +/** + * --------------------------------------------------------------------------- + * 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 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; + tenantId?: string; + 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; +} + +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; + + const parsedTenantId = tenantHeaderSchema.safeParse(tenantId); + if (!parsedTenantId.success) { + throw new AgentsetApiError({ + code: "bad_request", + message: "Invalid tenant ID.", + }); + } + + 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; + +/** + * 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 conditionalAuthMiddleware = base.middleware( + async ({ context, next, procedure }) => { + const headers = headersOf(context); + const authorizationHeader = 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 '?", + }); + } + 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, + }, + }); + } + + const session = await resolveSession(context); + if (!session) { + throw new AgentsetApiError({ + code: "unauthorized", + message: "Unauthorized: Invalid API key.", + }); + } + + const orgHeader = headers.get(ORG_HEADER); + if (!orgHeader) { + throw new AgentsetApiError({ + code: "bad_request", + message: `Missing ${ORG_HEADER} header.`, + }); + } + + // 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 (!member) { + throw new AgentsetApiError({ + code: "unauthorized", + message: "Unauthorized: You don't have access to this organization.", + }); + } + + const tenantId = getTenantFromHeaders(headers); + + return next({ + context: { + auth: { + type: "session", + session, + memberRole: member.role as MemberRole, + } satisfies ApiAuth as ApiAuth, + organization: member.organization, + apiScope: "all", + tenantId, + }, + }); + }, +); + +/** + * 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)`. Membership was + * already verified by the auth middleware, so the org-ownership check is the + * same for both credentials. + */ +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 } }); + }); + +/** 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 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. + */ +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; +}; + +/** + * 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 const toDashboardORPCError = (error: unknown): unknown => { + if (error instanceof AgentsetApiError) { + return new ORPCError(error.code.toUpperCase(), { + status: errorCodeToHttpStatus[error.code], + message: error.message, + }); + } + return error; +}; + +/** + * --------------------------------------------------------------------------- + * Dashboard-only procedures — better-auth session, no org header contract. + * These never carry `.route()` metadata: no REST path, no OpenAPI entry, no + * MCP tool. + * --------------------------------------------------------------------------- + */ + +export const protectedProcedure = base.use(async ({ context, next }) => { + const session = await resolveSession(context); + + if (!session) { + throw new ORPCError("UNAUTHORIZED"); + } + + return next({ + context: { + 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/router/api-keys.ts b/apps/web/src/server/orpc/router/api-keys.ts new file mode 100644 index 00000000..3deb1658 --- /dev/null +++ b/apps/web/src/server/orpc/router/api-keys.ts @@ -0,0 +1,200 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { + ApiKeySchema, + createApiKeyBodySchema, + CreatedApiKeySchema, +} from "@/schemas/api/api-key"; +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"; + +const keyIdPathSchema = z.string().meta({ + examples: ["cm4x1q2z90000abcd1234efgh"], + description: "The id of the API key.", + param: { + in: "path", + name: "keyId", + id: "ApiKeyIdRef", + }, +}); + +const list = api + .use(requireRoles("admin", "owner")) + .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 = api + .use(requireRoles("admin", "owner")) + .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 = api + .use(requireRoles("admin", "owner")) + .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, + + // ------------------------------------------------------------------------- + // 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/router/billing.ts b/apps/web/src/server/orpc/router/billing.ts new file mode 100644 index 00000000..f85f65f1 --- /dev/null +++ b/apps/web/src/server/orpc/router/billing.ts @@ -0,0 +1,302 @@ +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, + 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 = { + 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; + // 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, + }); + } + }), + 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; + // 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/router/chat.ts b/apps/web/src/server/orpc/router/chat.ts new file mode 100644 index 00000000..8b58911b --- /dev/null +++ b/apps/web/src/server/orpc/router/chat.ts @@ -0,0 +1,139 @@ +import { + checkSearchLimit, + incrementOrganizationSearchUsage, +} from "@/lib/api/usage"; +import { generateChat, streamChat } from "@/lib/chat"; +import { chatResponseSchema, chatSchema } from "@/schemas/api/chat"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; +import { api, requireNamespace, successSchema } from "@/server/orpc/base"; +import { toModelMessages } from "@/services/chat"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { z } from "zod/v4"; + +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 = api + .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( + // 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 + 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/openapi/v1/code-samples.ts b/apps/web/src/server/orpc/router/code-samples.ts similarity index 100% rename from apps/web/src/openapi/v1/code-samples.ts rename to apps/web/src/server/orpc/router/code-samples.ts diff --git a/apps/web/src/server/orpc/router/documents.ts b/apps/web/src/server/orpc/router/documents.ts new file mode 100644 index 00000000..800e932c --- /dev/null +++ b/apps/web/src/server/orpc/router/documents.ts @@ -0,0 +1,387 @@ +import { + documentIdPathSchema, + namespaceIdPathSchema, +} from "@/schemas/api/params"; +import { DocumentSchema, getDocumentsSchema } from "@/schemas/api/document"; +import { + api, + protectedProcedure, + 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 { 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 = api + .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 = api + .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 = api + .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 = api + .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 = api + .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 }; + }); + +/** + * 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, + getFileDownloadUrl, + getChunksDownloadUrl, +}; diff --git a/apps/web/src/server/api/auth.ts b/apps/web/src/server/orpc/router/helpers.ts similarity index 68% rename from apps/web/src/server/api/auth.ts rename to apps/web/src/server/orpc/router/helpers.ts index 1fc728f5..b1f3faa3 100644 --- a/apps/web/src/server/api/auth.ts +++ b/apps/web/src/server/orpc/router/helpers.ts @@ -1,10 +1,11 @@ +import type { ProtectedContext } from "@/server/orpc/base"; import { cache } from "react"; -import type { ProtectedProcedureContext } from "./trpc"; +import { db } from "@agentset/db/client"; export const getNamespaceByUser = cache( async ( - ctx: ProtectedProcedureContext, + ctx: Pick, idOrSlug: | { id: string; @@ -13,7 +14,7 @@ export const getNamespaceByUser = cache( slug: string; }, ) => { - return await ctx.db.namespace.findFirst({ + return await db.namespace.findFirst({ where: { ...("id" in idOrSlug ? { id: idOrSlug.id } : { slug: idOrSlug.slug }), organization: { diff --git a/apps/web/src/server/orpc/router/hosting.ts b/apps/web/src/server/orpc/router/hosting.ts new file mode 100644 index 00000000..8d36cdab --- /dev/null +++ b/apps/web/src/server/orpc/router/hosting.ts @@ -0,0 +1,384 @@ +import type { ProtectedContext } from "@/server/orpc/base"; +import { AgentsetApiError } from "@/lib/api/errors"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; +import { + addDomainSchema, + DomainSchema, + DomainStatusSchema, + HostingSchema, + updateHostingSchema, +} from "@/schemas/api/hosting"; +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"; +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 = api + .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 = api + .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 = api + .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 = api + .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 = api + .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 = api + .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 = api + .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 }); + }); + +// ----------------------------------------------------------------------------- +// 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, + update, + updatePut, + delete: del, + 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/router/ingest-jobs.ts b/apps/web/src/server/orpc/router/ingest-jobs.ts new file mode 100644 index 00000000..112014fa --- /dev/null +++ b/apps/web/src/server/orpc/router/ingest-jobs.ts @@ -0,0 +1,392 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { + createIngestJobSchema, + getIngestionJobsSchema, + IngestJobSchema, +} from "@/schemas/api/ingest-job"; +import { jobIdPathSchema, namespaceIdPathSchema } from "@/schemas/api/params"; +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"; +import { isFreePlan } from "@agentset/stripe/plans"; +import { normalizeId, prefixId } from "@agentset/utils"; + +import { makeCodeSamples, ts } from "./code-samples"; +import { getNamespaceByUser } from "./helpers"; + +const list = api + .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 = api + .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 = api + .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 = api + .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"], + // 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({ + 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 = api + .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_") }, + }; + }); + +/** + * 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, + delete: del, + reIngest, +}; diff --git a/apps/web/src/server/orpc/router/namespaces.ts b/apps/web/src/server/orpc/router/namespaces.ts new file mode 100644 index 00000000..be5979fd --- /dev/null +++ b/apps/web/src/server/orpc/router/namespaces.ts @@ -0,0 +1,432 @@ +import { namespaceIdPathSchema } from "@/schemas/api/params"; +import { + createNamespaceSchema, + NamespaceSchema, + updateNamespaceSchema, +} from "@/schemas/api/namespace"; +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 = api + .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 = api + .use(requireRoles("admin", "owner")) + .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 = api + .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(({ context }) => { + return { + success: true as const, + data: { + ...context.namespace, + id: prefixId(context.namespace.id, "ns_"), + organizationId: prefixId(context.namespace.organizationId, "org_"), + }, + }; + }); + +const updateHandler = api + .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 = api + .use(requireRoles("admin", "owner")) + .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, + + // --------------------------------------------------------------------- + // 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/router/organization.ts b/apps/web/src/server/orpc/router/organization.ts new file mode 100644 index 00000000..f7c12ee4 --- /dev/null +++ b/apps/web/src/server/orpc/router/organization.ts @@ -0,0 +1,273 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +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 = api + .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 = api + .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 listMembers = api + .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 = { + // shared (REST + MCP + dashboard) + get, + update, + updatePut, + 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/router/search.ts b/apps/web/src/server/orpc/router/search.ts new file mode 100644 index 00000000..33a34506 --- /dev/null +++ b/apps/web/src/server/orpc/router/search.ts @@ -0,0 +1,142 @@ +import { + checkSearchLimit, + incrementOrganizationSearchUsage, +} from "@/lib/api/usage"; +import { NodeSchema } from "@/schemas/api/node"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; +import { queryVectorStoreSchema } from "@/schemas/api/query"; +import { + api, + protectedProcedure, + requireNamespace, + successSchema, +} from "@/server/orpc/base"; +import { searchNamespace } from "@/services/search"; +import { toOpenAPISchema } from "@orpc/openapi"; +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 search = api + .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 }; + }); + +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 = { + search, + playground, +}; diff --git a/apps/web/src/server/orpc/router/uploads.ts b/apps/web/src/server/orpc/router/uploads.ts new file mode 100644 index 00000000..de5a2522 --- /dev/null +++ b/apps/web/src/server/orpc/router/uploads.ts @@ -0,0 +1,167 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; +import { + batchUploadSchema, + uploadFileSchema, + UploadResultSchema, +} from "@/schemas/api/upload"; +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 = api + .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. Files are limited to 5 MB on the Free plan and 200 MB on paid plans.", + 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, + plan: context.organization.plan, + file: { + fileName: input.fileName, + contentType: input.contentType, + fileSize: input.fileSize, + }, + }); + + if (!result.success) { + throw new AgentsetApiError({ + code: + result.code === "file_too_large" + ? "rate_limit_exceeded" + : "internal_server_error", + message: result.error, + }); + } + + return { success: true as const, data: result.data }; + }); + +const createBatch = api + .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. Files are limited to 5 MB on the Free plan and 200 MB on paid plans.", + 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, + plan: context.organization.plan, + files: input.files, + }); + + if (!result.success) { + throw new AgentsetApiError({ + code: + result.code === "file_too_large" + ? "rate_limit_exceeded" + : "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/router/warm-up.ts b/apps/web/src/server/orpc/router/warm-up.ts new file mode 100644 index 00000000..6f5d4b56 --- /dev/null +++ b/apps/web/src/server/orpc/router/warm-up.ts @@ -0,0 +1,86 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { namespaceIdPathSchema } from "@/schemas/api/params"; +import { api, 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 = api + .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/router/webhooks.ts b/apps/web/src/server/orpc/router/webhooks.ts new file mode 100644 index 00000000..e6fb7669 --- /dev/null +++ b/apps/web/src/server/orpc/router/webhooks.ts @@ -0,0 +1,458 @@ +import { AgentsetApiError } from "@/lib/api/errors"; +import { samplePayload } from "@/lib/webhook/sample-events/payload"; +import { + createWebhookSchema, + updateWebhookSchema, + WebhookDetailsSchema, + WebhookSchema, + WebhookSummarySchema, +} from "@/schemas/api/webhook"; +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"; +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"; + +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 = api + .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 = api + .use(requireRoles("admin", "owner")) + .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 = api + .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 = api + .use(requireRoles("admin", "owner")) + .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 = api + .use(requireRoles("admin", "owner")) + .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 = api + .use(requireRoles("admin", "owner")) + .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), + }; + }); + +/** + * --------------------------------------------------------------------------- + * 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, + get, + update, + 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 new file mode 100644 index 00000000..fb375d47 --- /dev/null +++ b/apps/web/src/server/orpc/spec.ts @@ -0,0 +1,955 @@ +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 { appRouter } from "./router"; + +/** + * 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. + */ + +/* ------------------------------------------------------------------------- + * 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(appRouter, { + info: INFO, + servers: SERVERS, + components: { securitySchemes: SECURITY_SCHEMES as never }, + commonSchemas: commonSchemas as never, + // 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); +}; 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 c622def2..23c99139 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, @@ -43,3 +46,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 7fd91866..ca5a7f1c 100644 --- a/apps/web/src/services/hosting/update.ts +++ b/apps/web/src/services/hosting/update.ts @@ -66,6 +66,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 ab9a2c4e..531122df 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,12 +111,15 @@ export const createIngestJob = async ({ fileUrl: data.payload.fileUrl, ...commonPayload, }; - } else if (data.payload.type === "MANAGED_FILE") { + } else { const exists = validateNamespaceFileKey(namespaceId, data.payload.key) && (await checkFileExists(data.payload.key)); if (!exists) { - throw new Error("FILE_NOT_FOUND"); + throw new AgentsetApiError({ + code: "bad_request", + message: "File not found", + }); } finalPayload = { @@ -104,11 +130,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" || @@ -139,38 +161,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({ @@ -185,7 +222,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/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 796bf741..17daad38 100644 --- a/bun.lock +++ b/bun.lock @@ -32,12 +32,16 @@ "@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", + "@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", @@ -48,6 +52,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", @@ -57,14 +62,12 @@ "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", "virtua": "^0.48.3", "zod": "catalog:", "zod-error": "catalog:", - "zod-openapi": "catalog:", "zustand": "^5.0.10", }, "devDependencies": { @@ -884,7 +887,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=="], @@ -966,6 +969,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=="], @@ -1192,6 +1227,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=="], @@ -1442,12 +1489,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=="], @@ -1708,7 +1749,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=="], @@ -1786,6 +1827,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=="], @@ -1822,7 +1865,7 @@ "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=="], @@ -2116,9 +2159,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=="], @@ -2204,6 +2247,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=="], @@ -2302,7 +2347,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=="], @@ -2446,7 +2491,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=="], @@ -2586,6 +2631,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=="], @@ -2790,6 +2837,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=="], @@ -2944,6 +2993,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=="], @@ -2978,6 +3029,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=="], @@ -3204,7 +3257,7 @@ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], - "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=="], @@ -3306,7 +3359,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=="], @@ -3430,6 +3483,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=="], @@ -3474,8 +3529,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=="], @@ -3688,9 +3741,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=="], @@ -3716,8 +3767,14 @@ "@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=="], + "@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=="], @@ -3848,6 +3905,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=="], @@ -3918,8 +3977,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=="], @@ -4012,7 +4069,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=="], @@ -4056,6 +4117,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "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=="], @@ -4112,6 +4175,8 @@ "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=="], "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -4142,6 +4207,10 @@ "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=="], "mermaid/dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], @@ -4156,6 +4225,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "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=="], "next-themes/react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], @@ -4230,8 +4301,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=="], @@ -4282,6 +4351,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=="], @@ -4640,8 +4711,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=="], @@ -4694,6 +4763,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=="], @@ -4830,18 +4901,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=="], @@ -4852,6 +4911,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=="], @@ -5038,8 +5107,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=="], @@ -5072,15 +5139,13 @@ "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=="], - - "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/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "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=="], + "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/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "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=="], - "shadcn/@modelcontextprotocol/sdk/express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "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=="], @@ -5092,14 +5157,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/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 81ecb0ef..b2b51ae8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -68,6 +68,7 @@ "production/hosting-ui", "production/data-segregation", "production/observability", + "production/agent-first", "production/mcp-server" ] }, @@ -132,7 +133,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" ] }, { @@ -161,19 +163,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 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`)