diff --git a/README.md b/README.md index 7a07809..c6323a3 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ GraphQL Analyzer includes advanced security testing for: ## ⚙️ Requirements -- **Caido** v0.51.2 or higher +- **Caido** v0.57.0 or higher ## 🤝 Contributing diff --git a/caido.config.ts b/caido.config.ts index c41fd69..7856ad5 100644 --- a/caido.config.ts +++ b/caido.config.ts @@ -1,18 +1,20 @@ -import { defineConfig } from '@caido-community/dev'; -import vue from '@vitejs/plugin-vue'; +import path from "path"; + +import tailwindCaido from "@caido/tailwindcss"; +import { defineConfig } from "@caido-community/dev"; +import vue from "@vitejs/plugin-vue"; +import prefixwrap from "postcss-prefixwrap"; import tailwindcss from "tailwindcss"; // @ts-expect-error no declared types at this time import tailwindPrimeui from "tailwindcss-primeui"; -import tailwindCaido from "@caido/tailwindcss"; -import path from "path"; -import prefixwrap from "postcss-prefixwrap"; const id = "graphql-analyzer"; export default defineConfig({ id, name: "GraphQL Analyzer", - description: "Plugin for GraphQL schema discovery, visualization, and advanced security", - version: "1.0.4", + description: + "Plugin for GraphQL schema discovery, visualization, and advanced security", + version: "1.0.5", author: { name: "Amr Elsagaei", email: "info@amrelsagaei.com", @@ -25,9 +27,9 @@ export default defineConfig({ root: "packages/backend", }, { - kind: 'frontend', + kind: "frontend", id: "frontend", - root: 'packages/frontend', + root: "packages/frontend", backend: { id: "backend", }, @@ -36,21 +38,20 @@ export default defineConfig({ build: { rollupOptions: { external: [ - '@caido/frontend-sdk', - "@codemirror/autocomplete", - "@codemirror/commands", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/search", - "@codemirror/state", - "@codemirror/view", - "@lezer/common", - "@lezer/highlight", + "@caido/frontend-sdk", + "@codemirror/autocomplete", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", "@lezer/lr", "vue", - - ] - } + ], + }, }, resolve: { alias: [ @@ -72,25 +73,24 @@ export default defineConfig({ preflight: false, }, content: [ - './packages/frontend/src/**/*.{vue,ts}', - './node_modules/@caido/primevue/dist/primevue.mjs' + "./packages/frontend/src/**/*.{vue,ts}", + "./node_modules/@caido/primevue/dist/primevue.mjs", ], // Check the [data-mode="dark"] attribute on the element to determine the mode // This attribute is set in the Caido core application darkMode: ["selector", '[data-mode="dark"]'], plugins: [ - // This plugin injects the necessary Tailwind classes for PrimeVue components tailwindPrimeui, // This plugin injects the necessary Tailwind classes for the Caido theme tailwindCaido, ], - }) - ] - } - } - } - } - ] -}); \ No newline at end of file + }), + ], + }, + }, + }, + }, + ], +}); diff --git a/packages/backend/package.json b/packages/backend/package.json index dcb0046..5aaad0f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -11,6 +11,6 @@ "zod": "4.3.6" }, "devDependencies": { - "@caido/sdk-backend": "^0.51.0" + "@caido/sdk-backend": "0.57.0" } } diff --git a/packages/backend/src/services/graphql/client.ts b/packages/backend/src/services/graphql/client.ts index 622f98b..118580d 100644 --- a/packages/backend/src/services/graphql/client.ts +++ b/packages/backend/src/services/graphql/client.ts @@ -1,8 +1,8 @@ import type { SDK } from "caido:plugin"; import { RequestSpec } from "caido:utils"; import type { GraphQLSchema, Result } from "shared"; +import { INTROSPECTION_QUERY } from "shared"; -import { INTROSPECTION_QUERY } from "./introspection"; import { mapHttpStatusToError, mergeHeaders, diff --git a/packages/backend/src/services/graphql/introspection.ts b/packages/backend/src/services/graphql/introspection.ts deleted file mode 100644 index 43755e2..0000000 --- a/packages/backend/src/services/graphql/introspection.ts +++ /dev/null @@ -1,89 +0,0 @@ -export const INTROSPECTION_QUERY = ` - query IntrospectionQuery { - __schema { - queryType { name } - mutationType { name } - subscriptionType { name } - types { - ...FullType - } - } - } - - fragment FullType on __Type { - kind - name - description - fields(includeDeprecated: true) { - name - description - args { - ...InputValue - } - type { - ...TypeRef - } - isDeprecated - deprecationReason - } - inputFields { - ...InputValue - } - interfaces { - ...TypeRef - } - enumValues(includeDeprecated: true) { - name - description - isDeprecated - deprecationReason - } - possibleTypes { - ...TypeRef - } - } - - fragment InputValue on __InputValue { - name - description - type { ...TypeRef } - defaultValue - } - - fragment TypeRef on __Type { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - ofType { - kind - name - } - } - } - } - } - } - } - } - } -`; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 051e90f..083f9de 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -9,6 +9,7 @@ "@caido/primevue": "0.1.2", "@codemirror/lang-javascript": "6.2.1", "@codemirror/lang-json": "6.0.1", + "@codemirror/language": "6.11.3", "@codemirror/state": "6.4.1", "@codemirror/theme-one-dark": "6.1.2", "@codemirror/view": "6.28.1", @@ -16,11 +17,13 @@ "@vueuse/core": "10.5.0", "codemirror": "6.0.1", "primevue": "4.1.0", - "vue": "3.4.37" + "ts-http-forge": "0.1.7", + "vue": "3.4.37", + "zod": "4.3.6" }, "devDependencies": { - "@caido/sdk-backend": "^0.51.0", - "@caido/sdk-frontend": "^0.51.2-beta.0", + "@caido/sdk-backend": "0.57.0", + "@caido/sdk-frontend": "0.57.1-beta.6", "backend": "workspace:*", "shared": "workspace:*", "vue-tsc": "2.0.29" diff --git a/packages/frontend/src/components/attacks/Container.vue b/packages/frontend/src/components/attacks/Container.vue index 4ff5601..e75b371 100644 --- a/packages/frontend/src/components/attacks/Container.vue +++ b/packages/frontend/src/components/attacks/Container.vue @@ -1375,45 +1375,29 @@ const createFindingFromResult = async (result: AttackResult) => { }; const sendToReplay = async (result: AttackResult) => { - try { - if (result.rawRequest === undefined || result.rawRequest === "") { - sdk.window.showToast("No request data available for replay", { - variant: "error", - }); - return; - } - - let domain = "Unknown"; - try { - if (result.targetUrl) { - const url = new URL(result.targetUrl); - domain = url.hostname; - } - } catch (error) { - domain = "Unknown"; - } + if (result.rawRequest === undefined || result.rawRequest === "") { + sdk.window.showToast("No request data available for replay", { + variant: "error", + }); + return; + } - const replayResult = await replayService.createReplayFromRequest( - result.rawRequest, - domain, - ); + const replayResult = await replayService.createReplayFromRequest( + result.rawRequest, + result.targetUrl, + ); - if (replayResult.kind === "Ok") { - sdk.window.showToast( - `Created replay session: ${replayResult.value.sessionName}`, - { variant: "success" }, - ); - } else { - sdk.window.showToast(`Failed to create replay: ${replayResult.error}`, { - variant: "error", - }); - } - } catch (error) { - sdk.window.showToast( - `Error sending to replay: ${error instanceof Error ? error.message : "Unknown error"}`, - { variant: "error" }, - ); + if (replayResult.kind === "Error") { + sdk.window.showToast(`Failed to create replay: ${replayResult.error}`, { + variant: "error", + }); + return; } + + sdk.window.showToast( + `Created replay session: ${replayResult.value.sessionName}`, + { variant: "success" }, + ); }; const getAttackTypeLabel = (attackType: string) => { diff --git a/packages/frontend/src/components/common/CodeEditor.vue b/packages/frontend/src/components/common/CodeEditor.vue index 959c5a8..dff4696 100644 --- a/packages/frontend/src/components/common/CodeEditor.vue +++ b/packages/frontend/src/components/common/CodeEditor.vue @@ -7,6 +7,8 @@ import { lineNumbers } from "@codemirror/view"; import { EditorView } from "codemirror"; import { onMounted, onUnmounted, ref, watch } from "vue"; +import { graphqlLanguage } from "./graphqlHighlight"; + const props = defineProps<{ content: string; language?: "json" | "javascript" | "graphql"; @@ -26,8 +28,9 @@ const getLanguageExtension = () => { switch (props.language) { case "json": return json(); - case "javascript": case "graphql": + return graphqlLanguage; + case "javascript": return javascript(); default: return javascript(); diff --git a/packages/frontend/src/components/common/graphqlHighlight.ts b/packages/frontend/src/components/common/graphqlHighlight.ts new file mode 100644 index 0000000..3e1b75f --- /dev/null +++ b/packages/frontend/src/components/common/graphqlHighlight.ts @@ -0,0 +1,40 @@ +import { StreamLanguage } from "@codemirror/language"; + +const keywords = new Set([ + "query", + "mutation", + "subscription", + "fragment", + "on", + "true", + "false", + "null", +]); + +export const graphqlLanguage = StreamLanguage.define({ + token(stream) { + if (stream.eatSpace()) return null; + + if (stream.match(/^#.*/) !== null) return "comment"; + if (stream.match(/^"(?:[^"\\]|\\.)*"/) !== null) return "string"; + if (stream.match(/^\$[_A-Za-z][_A-Za-z0-9]*/) !== null) + return "variableName"; + if (stream.match(/^@[_A-Za-z][_A-Za-z0-9]*/) !== null) return "meta"; + if (stream.match(/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/) !== null) { + return "number"; + } + + const word = stream.match(/^[_A-Za-z][_A-Za-z0-9]*/); + if (word !== null && typeof word !== "boolean") { + const name = word[0] ?? ""; + if (keywords.has(name)) return "keyword"; + if (/^[A-Z]/.test(name)) return "typeName"; + return "propertyName"; + } + + if (stream.match(/^[{}()[\]!:=,|&.]/) !== null) return "punctuation"; + + stream.next(); + return null; + }, +}); diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 753c2d3..4b22b98 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -1,11 +1,13 @@ import { Classic } from "@caido/primevue"; +import { type RequestDraft, type RequestFull } from "@caido/sdk-frontend"; import PrimeVue from "primevue/config"; import Tooltip from "primevue/tooltip"; -import { createApp } from "vue"; +import { type Component, createApp, markRaw } from "vue"; import { SDKPlugin } from "./plugins/sdk"; import "./styles/index.css"; import type { FrontendSDK } from "./types"; +import { isGraphQLRequest } from "./utils/graphql"; import App from "./views/App.vue"; import GraphQLViewMode from "./views/GraphQLViewMode.vue"; @@ -39,69 +41,18 @@ export const init = (sdk: FrontendSDK) => { icon: "fas fa-project-diagram", }); - // Detect whether a raw HTTP request contains a GraphQL query - function isGraphQLRequest(raw: string): boolean { - if (raw === "" || raw.trim() === "") return false; - - // Split headers from body - let parts = raw.split("\r\n\r\n"); - if (parts.length < 2) { - parts = raw.split("\n\n"); - if (parts.length < 2) return false; - } - - const headerSection = parts[0] ?? ""; - const firstLine = headerSection.split(/\r?\n/)[0] ?? ""; - - // Must be a POST request - if (!firstLine.startsWith("POST ")) return false; - - const separator = raw.includes("\r\n") ? "\r\n\r\n" : "\n\n"; - const body = parts.slice(1).join(separator).trim(); - if (!body) return false; - - try { - const parsed = JSON.parse(body) as { query?: unknown }; - return typeof parsed.query === "string" && parsed.query.trim() !== ""; - } catch { - return false; - } - } - - type ViewModeOptions = { - label: string; - view: { component: unknown }; - when?: (...args: unknown[]) => boolean; - }; - - type ExtendedViewModeSDK = { - addRequestViewMode: (options: ViewModeOptions) => void; - }; - - const requestViewMode: ViewModeOptions = { + const viewMode = { label: "GraphQL", - view: { component: GraphQLViewMode }, - when: (request: unknown) => { - const req = request as { raw?: string } | undefined; - return isGraphQLRequest(req?.raw ?? ""); - }, + view: { component: markRaw(GraphQLViewMode) as Component }, + when: (request: RequestFull | RequestDraft) => + isGraphQLRequest(request.raw), }; - const surfaces = [ - sdk.httpHistory, - sdk.replay, - sdk.search, - sdk.sitemap, - sdk.intercept, - ] as unknown as ExtendedViewModeSDK[]; - - for (const surface of surfaces) { - try { - surface.addRequestViewMode(requestViewMode); - } catch { - // ignore - } - } + sdk.httpHistory.addRequestViewMode(viewMode); + sdk.search.addRequestViewMode(viewMode); + sdk.sitemap.addRequestViewMode(viewMode); + sdk.replay.addRequestViewMode(viewMode); + sdk.intercept.addRequestViewMode(viewMode); sdk.commands.register("graphql-analyzer-scan", { name: "Scan GraphQL Endpoint", diff --git a/packages/frontend/src/services/replay.ts b/packages/frontend/src/services/replay.ts index 75fce4b..37a03e7 100644 --- a/packages/frontend/src/services/replay.ts +++ b/packages/frontend/src/services/replay.ts @@ -1,340 +1,99 @@ -import type { FrontendSDK } from "../plugins/sdk"; +import type { Result } from "shared"; -export type Result = - | { kind: "Ok"; value: T } - | { kind: "Error"; error: string }; +import type { FrontendSDK } from "@/plugins/sdk"; -type RequestSpec = { - method: string; +type ConnectionInfo = { host: string; port: number; - path: string; - query: string; - headers: Record; - body: string; - tls: boolean; - url: string; + isTLS: boolean; }; -export class GraphQLReplayService { - private sdk: FrontendSDK; - private collections: Map = new Map(); +const collectionPrefix = "GraphQL - "; - constructor(sdk: FrontendSDK) { - this.sdk = sdk; +const parseConnection = (targetUrl: string): ConnectionInfo | undefined => { + if (!URL.canParse(targetUrl)) { + return undefined; } - async createReplayFromRequest( - rawRequest: string, - domain: string, - ): Promise> { - try { - const parsedRequest = this.parseRawHttpRequest(rawRequest); - if (parsedRequest === null) { - return { kind: "Error", error: "Failed to parse HTTP request" }; - } - - const collectionName = this.getOrCreateCollection(domain); - - if (parsedRequest === undefined) { - return { - kind: "Error", - error: "Failed to parse HTTP request", - }; - } - const sessionName = this.generateSessionName(parsedRequest); - - const requestSpec = this.buildRequestSpec(parsedRequest); - if (requestSpec === undefined) { - return { - kind: "Error", - error: "Failed to build request specification", - }; - } - - await this.sendToReplay(requestSpec, collectionName, sessionName); - - return { - kind: "Ok", - value: { - collectionName, - sessionName, - }, - }; - } catch (error) { - return { - kind: "Error", - error: error instanceof Error ? error.message : "Unknown error", - }; - } + const url = new URL(targetUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return undefined; } - - private getOrCreateCollection(domain: string): string { - const collectionName = `GraphQL - ${domain}`; - - if (!this.collections.has(domain)) { - this.collections.set(domain, collectionName); - } - - return collectionName; + if (url.hostname === "") { + return undefined; } - private parseRawHttpRequest( - rawRequest: string, - ): ParsedHttpRequest | undefined { - try { - const lines = rawRequest.split("\n"); - if (lines.length === 0) return undefined; - - const requestLine = lines[0]?.trim(); - if (requestLine === undefined || requestLine === "") return undefined; - const parts = requestLine.split(" "); - const method = parts[0]; - const path = parts[1]; - const protocol = parts[2]; - if (method === undefined || path === undefined) return undefined; - - const headers: Record = {}; - let bodyStartIndex = -1; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]?.trim(); - if (line === undefined || line === "") { - bodyStartIndex = i + 1; - break; - } + const isTLS = url.protocol === "https:"; + const port = url.port !== "" ? Number(url.port) : isTLS ? 443 : 80; - const colonIndex = line.indexOf(":"); - if (colonIndex > 0) { - const headerName = line.substring(0, colonIndex).trim(); - const headerValue = line.substring(colonIndex + 1).trim(); - if (headerName !== "" && headerValue !== "") { - headers[headerName] = headerValue; - } - } - } + return { host: url.hostname, port, isTLS }; +}; - let body = ""; - if (bodyStartIndex > 0 && bodyStartIndex < lines.length) { - body = lines.slice(bodyStartIndex).join("\n").trim(); - } +const sessionNameFromRaw = (rawRequest: string): string => { + const parts = (rawRequest.split("\n")[0]?.trim() ?? "").split(" "); + const method = parts[0] ?? "POST"; + const path = (parts[1] ?? "/").split("?")[0] ?? "/"; - const host = headers["Host"] ?? headers["host"] ?? "localhost"; - const tls = - (protocol !== undefined && protocol.includes("HTTPS")) || - headers["X-Forwarded-Proto"] === "https"; + return `${method} ${path}`; +}; - let port = 80; - if (tls === true) port = 443; - if (host.includes(":")) { - const parts = host.split(":"); - const portStr = parts[1]; - if (portStr !== undefined && portStr !== "") { - const parsedPort = parseInt(portStr); - port = Number.isNaN(parsedPort) ? port : parsedPort; - } - } +export class GraphQLReplayService { + private sdk: FrontendSDK; - return { - method: method.toUpperCase(), - path: path ?? "/", - host: host.split(":")[0] ?? "localhost", - port, - tls, - headers, - body, - protocol: protocol ?? "HTTP/1.1", - }; - } catch (error) { - return undefined; - } + constructor(sdk: FrontendSDK) { + this.sdk = sdk; } - private buildRequestSpec( - parsedRequest: ParsedHttpRequest, - ): RequestSpec | undefined { - try { - const protocol = parsedRequest.tls === true ? "https" : "http"; - const portStr = - (parsedRequest.tls === true && parsedRequest.port === 443) || - (parsedRequest.tls === false && parsedRequest.port === 80) - ? "" - : `:${parsedRequest.port}`; - const url = `${protocol}://${parsedRequest.host}${portStr}${parsedRequest.path}`; - - const [pathname, queryString] = parsedRequest.path.split("?"); - - return { - method: parsedRequest.method, - host: parsedRequest.host, - port: parsedRequest.port, - path: pathname ?? "/", - query: queryString ?? "", - headers: parsedRequest.headers, - body: parsedRequest.body, - tls: parsedRequest.tls, - url, - }; - } catch (error) { - return undefined; + async createReplayFromRequest( + rawRequest: string, + targetUrl: string, + ): Promise> { + const connection = parseConnection(targetUrl); + if (connection === undefined) { + return { kind: "Error", error: "Invalid target URL" }; } - } - private generateSessionName(parsedRequest: ParsedHttpRequest): string { - const path = parsedRequest.path.split("?")[0]; - return `${parsedRequest.method} ${path}`; - } + const collectionName = `${collectionPrefix}${connection.host}`; + const sessionName = sessionNameFromRaw(rawRequest); - private async sendToReplay( - requestSpec: RequestSpec, - collectionName: string, - sessionName: string, - ): Promise { try { - let collectionId: string | undefined; - - try { - const collections = this.sdk.replay.getCollections(); - type Collection = { name: string; id?: string }; - const existingCollection = collections.find( - (c: Collection) => c.name === collectionName, - ); - collectionId = existingCollection?.id; - } catch { - // Ignore replay SDK errors if not available - collectionId = undefined; - } - - if (collectionId === undefined) { - const createCollectionResult = - await this.sdk.graphql.createReplaySessionCollection({ - input: { - name: collectionName, - }, - }); - - collectionId = - createCollectionResult.createReplaySessionCollection?.collection?.id; - if (collectionId === undefined) { - throw new Error("Failed to create replay collection"); - } - } - - const rawRequest = this.buildRawHttpRequest(requestSpec); - - const createSessionResult = await this.sdk.graphql.createReplaySession({ - input: { - requestSource: { - raw: { - raw: rawRequest, - connectionInfo: { - host: requestSpec.host ?? "localhost", - port: requestSpec.port ?? (requestSpec.tls === true ? 443 : 80), - isTLS: requestSpec.tls === true, - }, - }, - }, - }, - }); - - const sessionId = createSessionResult.createReplaySession?.session?.id; - if (sessionId === undefined) { - throw new Error("Failed to create replay session"); - } - - try { - type ReplaySDK = { - moveSession?: ( - sessionId: string, - collectionId: string, - ) => Promise; - }; - const replaySDK = this.sdk.replay as unknown as ReplaySDK; - if (replaySDK.moveSession !== undefined) { - await replaySDK.moveSession(sessionId, collectionId); - } - } catch { - // Ignore - } - - try { - await this.sdk.graphql.renameReplaySession({ - id: sessionId, - name: sessionName, - }); - } catch { - // Ignore - } - } catch (error) { - throw new Error( - `Failed to send to replay: ${error instanceof Error ? error.message : "Unknown error"}`, + const existing = this.sdk.replay + .getCollections() + .find((collection) => collection.name === collectionName); + const collectionId = + existing?.id ?? + (await this.sdk.replay.createCollection(collectionName)).id; + + const existingIds = new Set( + this.sdk.replay.getSessions().map((session) => session.id), ); - } - } - - private buildRawHttpRequest(spec: RequestSpec): string { - try { - const method = spec.method ?? "POST"; - const host = spec.host ?? "localhost"; - const port = spec.port ?? (spec.tls === true ? 443 : 80); - const path = spec.path ?? "/"; - const query = spec.query !== "" ? `?${spec.query}` : ""; - const headers = spec.headers ?? {}; - const body = spec.body ?? ""; - const isTls = spec.tls === true; - const fullPath = path + query; - let request = `${method} ${fullPath} HTTP/1.1\r\n`; - - if ( - (isTls === true && port !== 443) || - (isTls === false && port !== 80) - ) { - request += `Host: ${host}:${port}\r\n`; - } else { - request += `Host: ${host}\r\n`; - } - - for (const [name, value] of Object.entries(headers)) { - const lowerName = name.toLowerCase(); - if ( - name !== "" && - value !== "" && - lowerName !== "host" && - lowerName !== "content-length" - ) { - request += `${name}: ${value}\r\n`; - } - } - - if (typeof body === "string" && body.length > 0) { - request += `Content-Length: ${body.length}\r\n`; - } - - request += "\r\n"; + await this.sdk.replay.createSession( + { type: "Raw", raw: rawRequest, connectionInfo: connection }, + collectionId, + ); - if (typeof body === "string" && body.length > 0) { - request += body; + const created = this.sdk.replay + .getSessions() + .find( + (session) => + session.collectionId === collectionId && + !existingIds.has(session.id), + ); + if (created !== undefined) { + await this.sdk.replay.renameSession(created.id, sessionName); } - return request; + return { kind: "Ok", value: { collectionName, sessionName } }; } catch (error) { - return `POST / HTTP/1.1\r\nHost: localhost\r\n\r\n`; + return { + kind: "Error", + error: error instanceof Error ? error.message : "Unknown error", + }; } } } -interface ParsedHttpRequest { - method: string; - path: string; - host: string; - port: number; - tls: boolean; - headers: Record; - body: string; - protocol: string; -} - let replayServiceInstance: GraphQLReplayService | undefined = undefined; export function createReplayService(sdk: FrontendSDK): GraphQLReplayService { diff --git a/packages/frontend/src/utils/graphql.test.ts b/packages/frontend/src/utils/graphql.test.ts new file mode 100644 index 0000000..c72812e --- /dev/null +++ b/packages/frontend/src/utils/graphql.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + extractGraphQLOperation, + isGraphQLRequest, + parseHttpMessage, +} from "./graphql"; + +const rawRequest = (method: string, body: string): string => + [ + `${method} /graphql HTTP/1.1`, + "Host: example.com", + "Content-Type: application/json", + `Content-Length: ${body.length}`, + "", + body, + ].join("\r\n"); + +describe("isGraphQLRequest", () => { + it("detects a JSON body with a query field", () => { + const body = JSON.stringify({ + query: + "query IntrospectionQuery {\n __schema {\n queryType { name }\n }\n }", + variables: {}, + }); + expect(isGraphQLRequest(rawRequest("POST", body))).toBe(true); + }); + + it("detects a raw query body with an operation name", () => { + expect( + isGraphQLRequest(rawRequest("POST", "query GetUser { user { id } }")), + ).toBe(true); + }); + + it("detects an anonymous operation", () => { + expect(isGraphQLRequest(rawRequest("POST", "{ user { id } }"))).toBe(true); + }); + + it("detects a mutation", () => { + expect(isGraphQLRequest(rawRequest("POST", "mutation { logout }"))).toBe( + true, + ); + }); + + it("detects an Apollo persisted query without an inline query", () => { + const body = JSON.stringify({ + operationName: "TrackMutation", + variables: { event: "RUN_EXPLORER_OPERATION" }, + extensions: { persistedQuery: { version: 1, sha256Hash: "abc" } }, + }); + expect(isGraphQLRequest(rawRequest("POST", body))).toBe(true); + }); + + it("detects a JSON body with only an operationName", () => { + const body = JSON.stringify({ operationName: "GetUser", variables: {} }); + expect(isGraphQLRequest(rawRequest("POST", body))).toBe(true); + }); + + it("ignores non-GraphQL JSON", () => { + expect( + isGraphQLRequest(rawRequest("POST", JSON.stringify({ name: "x" }))), + ).toBe(false); + }); + + it("ignores non-POST requests", () => { + const body = JSON.stringify({ query: "{ user { id } }" }); + expect(isGraphQLRequest(rawRequest("GET", body))).toBe(false); + }); + + it("ignores empty input", () => { + expect(isGraphQLRequest("")).toBe(false); + }); +}); + +describe("extractGraphQLOperation", () => { + it("returns query, variables and operationName from JSON", () => { + const op = extractGraphQLOperation( + JSON.stringify({ + query: "query Q { a }", + variables: { id: 1 }, + operationName: "Q", + }), + ); + expect(op).toEqual({ + query: "query Q { a }", + variables: { id: 1 }, + operationName: "Q", + }); + }); + + it("returns the persisted query hash and empty query for an APQ body", () => { + const op = extractGraphQLOperation( + JSON.stringify({ + operationName: "UI__IdentityQuery", + variables: { accountId: null }, + extensions: { persistedQuery: { version: 1, sha256Hash: "0b60a8" } }, + }), + ); + expect(op?.query).toBe(""); + expect(op?.operationName).toBe("UI__IdentityQuery"); + expect(op?.persistedQueryHash).toBe("0b60a8"); + }); + + it("returns a raw query body", () => { + expect(extractGraphQLOperation("mutation M { x }")).toEqual({ + query: "mutation M { x }", + }); + }); + + it("returns undefined for a persisted query without a hash", () => { + expect( + extractGraphQLOperation( + JSON.stringify({ extensions: { persistedQuery: {} } }), + ), + ).toBeUndefined(); + }); + + it("returns undefined for JSON without a query", () => { + expect( + extractGraphQLOperation(JSON.stringify({ data: 1 })), + ).toBeUndefined(); + }); + + it("returns undefined for non-GraphQL text", () => { + expect(extractGraphQLOperation("hello world")).toBeUndefined(); + }); +}); + +describe("parseHttpMessage", () => { + it("parses the method and body", () => { + const message = parseHttpMessage(rawRequest("POST", "{ a }")); + expect(message?.method).toBe("POST"); + expect(message?.body).toBe("{ a }"); + }); +}); diff --git a/packages/frontend/src/utils/graphql.ts b/packages/frontend/src/utils/graphql.ts new file mode 100644 index 0000000..0312436 --- /dev/null +++ b/packages/frontend/src/utils/graphql.ts @@ -0,0 +1,88 @@ +import { HttpForge } from "ts-http-forge"; +import { z } from "zod"; + +export type HttpMessage = { + method: string; + body: string; +}; + +export type GraphQLOperation = { + query: string; + variables?: unknown; + operationName?: string; + persistedQueryHash?: string; +}; + +export const parseHttpMessage = (raw: string): HttpMessage | undefined => { + if (raw.trim() === "") return undefined; + + const forge = HttpForge.create(raw); + + return { + method: forge.getMethod() ?? "UNKNOWN", + body: forge.getBody() ?? "", + }; +}; + +const operationPattern = /^\s*(query|mutation|subscription|fragment)\b/i; +const anonymousPattern = /^\{\s*[A-Za-z_]/; + +const looksLikeGraphQLQuery = (text: string): boolean => { + const trimmed = text.trim(); + if (trimmed === "") return false; + return operationPattern.test(trimmed) || anonymousPattern.test(trimmed); +}; + +const graphqlBodySchema = z.object({ + query: z.string().optional(), + variables: z.unknown().optional(), + operationName: z.string().optional(), + extensions: z + .object({ + persistedQuery: z + .object({ sha256Hash: z.string().optional() }) + .optional(), + }) + .optional(), +}); + +export const extractGraphQLOperation = ( + body: string, +): GraphQLOperation | undefined => { + const trimmed = body.trim(); + if (trimmed === "") return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return looksLikeGraphQLQuery(trimmed) ? { query: trimmed } : undefined; + } + + const result = graphqlBodySchema.safeParse(parsed); + if (!result.success) return undefined; + + const data = result.data; + const persistedQueryHash = data.extensions?.persistedQuery?.sha256Hash; + const hasQuery = data.query !== undefined && data.query.trim() !== ""; + const hasOperationName = + data.operationName !== undefined && data.operationName.trim() !== ""; + + if (hasQuery || hasOperationName || persistedQueryHash !== undefined) { + return { + query: data.query ?? "", + variables: data.variables, + operationName: data.operationName, + persistedQueryHash, + }; + } + + return undefined; +}; + +export const isGraphQLRequest = (raw: string): boolean => { + const message = parseHttpMessage(raw); + if (message === undefined) return false; + if (message.method.toUpperCase() !== "POST") return false; + return extractGraphQLOperation(message.body) !== undefined; +}; diff --git a/packages/frontend/src/views/GraphQLViewMode.vue b/packages/frontend/src/views/GraphQLViewMode.vue index d3c4560..eecb59a 100644 --- a/packages/frontend/src/views/GraphQLViewMode.vue +++ b/packages/frontend/src/views/GraphQLViewMode.vue @@ -1,18 +1,28 @@ @@ -687,25 +429,24 @@ const saveChanges = () => {
-
- - {{ parsedHttp?.method || "POST" }} {{ request?.host || "unknown" - }}{{ request?.path || "/" }} +
+
+ severity="secondary" + text + @click="addIntrospectionQuery" + />
-
- - +
+
+
{
+ +
+ Persisted query. The query text is not sent inline. +
+
+ SHA-256: {{ graphqlData?.persistedQueryHash }} +
+
+
- +
- +
@@ -820,7 +565,7 @@ const saveChanges = () => { {
- +
- +
{
Method
- {{ parsedHttp?.method || "POST" }} + {{ endpoint.method }}
Host
- {{ request?.host || "unknown" }} + {{ endpoint.host }}
Path
- {{ request?.path || "/" }} + {{ endpoint.path }}
Port
- {{ - request?.port || (request?.port === 443 ? 443 : 80) - }} -
-
-
-
- -
-

- - Security Headers -

-
-
-
- {{ key }}: -
-
- {{ value }} -
-
-
-
- -
- No authentication or security headers detected -
-
- Looking for: Authorization, API Keys, Cookies, CSRF - tokens -
+ {{ endpoint.port }}
-
- +
+
{
- {{ request?.host || "unknown" }} + {{ endpoint.host }} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 331b366..12b43f3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -214,3 +214,93 @@ export type SchemaImportResult = { schema: GraphQLSchema; format: string; }; + +export const INTROSPECTION_QUERY = ` + query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { + ...FullType + } + } + } + + fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { + ...InputValue + } + type { + ...TypeRef + } + isDeprecated + deprecationReason + } + inputFields { + ...InputValue + } + interfaces { + ...TypeRef + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + deprecationReason + } + possibleTypes { + ...TypeRef + } + } + + fragment InputValue on __InputValue { + name + description + type { ...TypeRef } + defaultValue + } + + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } + } + } + } +`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7a314e..b2f90d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,8 +62,8 @@ importers: version: 4.3.6 devDependencies: '@caido/sdk-backend': - specifier: ^0.51.0 - version: 0.51.1 + specifier: 0.57.0 + version: 0.57.0 packages/frontend: dependencies: @@ -76,6 +76,9 @@ importers: '@codemirror/lang-json': specifier: 6.0.1 version: 6.0.1 + '@codemirror/language': + specifier: 6.11.3 + version: 6.11.3 '@codemirror/state': specifier: 6.4.1 version: 6.4.1 @@ -97,16 +100,22 @@ importers: primevue: specifier: 4.1.0 version: 4.1.0(vue@3.4.37(typescript@5.5.4)) + ts-http-forge: + specifier: 0.1.7 + version: 0.1.7 vue: specifier: 3.4.37 version: 3.4.37(typescript@5.5.4) + zod: + specifier: 4.3.6 + version: 4.3.6 devDependencies: '@caido/sdk-backend': - specifier: ^0.51.0 - version: 0.51.1 + specifier: 0.57.0 + version: 0.57.0 '@caido/sdk-frontend': - specifier: ^0.51.2-beta.0 - version: 0.51.2-beta.0(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(vue@3.4.37(typescript@5.5.4)) + specifier: 0.57.1-beta.6 + version: 0.57.1-beta.6(@ai-sdk/provider@3.0.10)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(vue@3.4.37(typescript@5.5.4)) backend: specifier: workspace:* version: link:../backend @@ -125,6 +134,10 @@ importers: packages: + '@ai-sdk/provider@3.0.10': + resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -163,21 +176,22 @@ packages: '@caido/primevue@0.1.2': resolution: {integrity: sha512-PCv6qyvlWEKB97PLRzPHDjs7ZGW5JQrD6jvJiMEiprfu4IR5/dXiqRJ4M5g293qn+RAffBuiXcna0vWSxrg5lQ==} - '@caido/quickjs-types@0.20.0': - resolution: {integrity: sha512-T3OTw3GjGPtMxASFHZro86Jce+9TVNNbUz2fQCaCbP+Yh+olt5mwTMLNz8uLFo4icgc1WPfyimfFSN23za9UXw==} + '@caido/quickjs-types@0.26.0': + resolution: {integrity: sha512-Nnpu87fnTvVxEdLUQXAc9d763vj13GPcQEhSWireuLlDPDgxM/7dK8kZdOhGX/BhWRRZ7nUzNc3IdN+OVdsoSA==} - '@caido/sdk-backend@0.51.1': - resolution: {integrity: sha512-BvDCcbxXCDcsCvqntpeoKJ475DUZ7H7TXZjIPcoa5WU+ynBpvnDpeoxVoy1UBYQKz9L5ld8MQ3S5oY9wEoNHqQ==} + '@caido/sdk-backend@0.57.0': + resolution: {integrity: sha512-GwPytq4g+lhrLXkwkAHOzp8CQuPEfU+zAC92o1HDZa0gkeDWwKB3j0zi9HgdF8dgyARY5MV3s67ebljGrC0ywQ==} - '@caido/sdk-frontend@0.51.2-beta.0': - resolution: {integrity: sha512-PJfPOQ8auWjjEyoOtBJr5ogzUMOk+uVqUh3QmjRcZNp4jcnm6hx6/M2O4PMHhXC3ihfCfHCqYnFyOLqgqXEpRg==} + '@caido/sdk-frontend@0.57.1-beta.6': + resolution: {integrity: sha512-Lx65PSitf5XifG2l4xF3c4kvO3Zj60CtL+n/cUuHXLFRLB1Dw4wYt/JXTRCysvxMoI9/OSB20rixH5tFpzrliw==} peerDependencies: + '@ai-sdk/provider': ^3.0.1 '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 vue: ^3.0.0 - '@caido/sdk-shared@0.1.1': - resolution: {integrity: sha512-JAV5ajUqxZdXYPTmDEvIKBZon8I5uHq44ATj0Nj3BVpllRDUGY9kcBd+PXMD50+3lv1CvhR3/f6q24T0+4aVJQ==} + '@caido/sdk-shared@0.2.2': + resolution: {integrity: sha512-qzfwXrjujNAmXxedQW2YI5Ls5h+Y/MvzHZxd10vnL6IJbRJStZGusDTnFYDWUyiQpCrc5kGuY3fLr8dYh3UHnw==} '@caido/tailwindcss@0.0.1': resolution: {integrity: sha512-BGp7s8BiZv6eBV8x/j0t5nPBVKP7Bm+gJVY4APcFgFkNkrRSRDo0VuXN52OhiHc/+vTg85lrmLO8IWMM5bcJrQ==} @@ -545,49 +559,41 @@ packages: resolution: {integrity: sha512-xlMh4gNtplNQEwuF5icm69udC7un0WyzT5ywOeHrPMEsghKnLjXok2wZgAA7ocTm9+JsI+nVXIQa5XO1x+HPQg==} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.16.2': resolution: {integrity: sha512-OZs33QTMi0xmHv/4P0+RAKXJTBk7UcMH5tpTaCytWRXls/DGaJ48jOHmriQGK2YwUqXl+oneuNyPOUO0obJ+Hg==} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.16.2': resolution: {integrity: sha512-UVyuhaV32dJGtF6fDofOcBstg9JwB2Jfnjfb8jGlu3xcG+TsubHRhuTwQ6JZ1sColNT1nMxBiu7zdKUEZi1kwg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.16.2': resolution: {integrity: sha512-YZZS0yv2q5nE1uL/Fk4Y7m9018DSEmDNSG8oJzy1TJjA1jx5HL52hEPxi98XhU6OYhSO/vC1jdkJeE8TIHugug==} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.16.2': resolution: {integrity: sha512-9VYuypwtx4kt1lUcwJAH4dPmgJySh4/KxtAPdRoX2BTaZxVm/yEXHq0mnl/8SEarjzMvXKbf7Cm6UBgptm3DZw==} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.16.2': resolution: {integrity: sha512-3gbwQ+xlL5gpyzgSDdC8B4qIM4mZaPDLaFOi3c/GV7CqIdVJc5EZXW4V3T6xwtPBOpXPXfqQLbhTnUD4SqwJtA==} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.16.2': resolution: {integrity: sha512-m0WcK0j54tSwWa+hQaJMScZdWneqE7xixp/vpFqlkbhuKW9dRHykPAFvSYg1YJ3MJgu9ZzVNpYHhPKJiEQq57Q==} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.16.2': resolution: {integrity: sha512-ZjUm3w96P2t47nWywGwj1A2mAVBI/8IoS7XHhcogWCfXnEI3M6NPIRQPYAZW4s5/u3u6w1uPtgOwffj2XIOb/g==} cpu: [x64] os: [linux] - libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.16.2': resolution: {integrity: sha512-OFVQ2x3VenTp13nIl6HcQ/7dmhFmM9dg2EjKfHcOtYfrVLQdNR6THFU7GkMdmc8DdY1zLUeilHwBIsyxv5hkwQ==} @@ -674,67 +680,56 @@ packages: resolution: {integrity: sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.50.0': resolution: {integrity: sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.50.0': resolution: {integrity: sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.50.0': resolution: {integrity: sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.50.0': resolution: {integrity: sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.50.0': resolution: {integrity: sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.50.0': resolution: {integrity: sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.50.0': resolution: {integrity: sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.50.0': resolution: {integrity: sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.50.0': resolution: {integrity: sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.50.0': resolution: {integrity: sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.50.0': resolution: {integrity: sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==} @@ -1254,6 +1249,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + cookie-es@2.0.1: + resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2094,6 +2092,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2793,6 +2794,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-http-forge@0.1.7: + resolution: {integrity: sha512-jPLYDBOCrx3nG8SJ7qrht7R2RiBrTmU7dhdLBMaky1YiojlSUNo/vHyTYpTSV2bK/l5biKlW1ZTOg/MXMmbGjA==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -3090,6 +3094,10 @@ packages: snapshots: + '@ai-sdk/provider@3.0.10': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@babel/helper-string-parser@7.27.1': {} @@ -3165,20 +3173,21 @@ snapshots: '@caido/primevue@0.1.2': {} - '@caido/quickjs-types@0.20.0': {} + '@caido/quickjs-types@0.26.0': {} - '@caido/sdk-backend@0.51.1': + '@caido/sdk-backend@0.57.0': dependencies: - '@caido/quickjs-types': 0.20.0 - '@caido/sdk-shared': 0.1.1 + '@caido/quickjs-types': 0.26.0 + '@caido/sdk-shared': 0.2.2 - '@caido/sdk-frontend@0.51.2-beta.0(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(vue@3.4.37(typescript@5.5.4))': + '@caido/sdk-frontend@0.57.1-beta.6(@ai-sdk/provider@3.0.10)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(vue@3.4.37(typescript@5.5.4))': dependencies: + '@ai-sdk/provider': 3.0.10 '@codemirror/state': 6.4.1 '@codemirror/view': 6.28.1 vue: 3.4.37(typescript@5.5.4) - '@caido/sdk-shared@0.1.1': {} + '@caido/sdk-shared@0.2.2': {} '@caido/tailwindcss@0.0.1': dependencies: @@ -4274,6 +4283,8 @@ snapshots: content-type@1.0.5: {} + cookie-es@2.0.1: {} + cookie-signature@1.2.2: {} cookie@0.6.0: {} @@ -5266,6 +5277,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -6024,6 +6037,10 @@ snapshots: dependencies: typescript: 5.5.4 + ts-http-forge@0.1.7: + dependencies: + cookie-es: 2.0.1 + ts-interface-checker@0.1.13: {} tsconfig-paths@3.15.0: