From 5cb19cc2a087718d8afc846a2200086b0b5cbd83 Mon Sep 17 00:00:00 2001 From: Jaeyoun Nam Date: Fri, 17 Jul 2026 07:49:31 -0700 Subject: [PATCH 1/3] feat(linear): add allow-listed graphql source api --- .../forms/linear-data-source-form.tsx | 53 +- packages/db/src/credentials.ts | 34 ++ packages/db/src/source-providers.ts | 2 + .../src/source-api/adapters/linear.test.ts | 152 +++++- .../server/src/source-api/adapters/linear.ts | 481 +++++++++++++++++- .../src/data-sources/linear-access-mode.tsx | 104 +++- 6 files changed, 801 insertions(+), 25 deletions(-) diff --git a/apps/dashboard/src/features/data-sources/forms/linear-data-source-form.tsx b/apps/dashboard/src/features/data-sources/forms/linear-data-source-form.tsx index 3621435b..6d0fc314 100644 --- a/apps/dashboard/src/features/data-sources/forms/linear-data-source-form.tsx +++ b/apps/dashboard/src/features/data-sources/forms/linear-data-source-form.tsx @@ -1,10 +1,20 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { LinearAccessModeSchema } from "@onequery/db/credentials"; +import { + LinearAccessModeSchema, + LinearGraphqlAllowListItemSchema, +} from "@onequery/db/credentials"; import type { LinearApiKeyCredentials } from "@onequery/db/credentials"; import { Input } from "@onequery/ui/components/input"; import { Label } from "@onequery/ui/components/label"; -import { LinearAccessModeSelector } from "@onequery/ui/data-sources/linear-access-mode"; -import type { LinearAccessMode } from "@onequery/ui/data-sources/linear-access-mode"; +import { + filterLinearGraphqlAllowListForAccessMode, + LinearAccessModeSelector, + LinearGraphqlAllowListSelector, +} from "@onequery/ui/data-sources/linear-access-mode"; +import type { + LinearAccessMode, + LinearGraphqlAllowListItem, +} from "@onequery/ui/data-sources/linear-access-mode"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -22,6 +32,7 @@ import { applyDataSourceNameConflictError } from "./data-source-errors"; const LinearDataSourceFormSchema = z.object({ accessMode: LinearAccessModeSchema, apiKey: z.string().min(1, "API key is required"), + graphqlAllowList: z.array(LinearGraphqlAllowListItemSchema).default([]), name: z.string().min(1, "Name is required"), }); @@ -40,11 +51,15 @@ export function LinearDataSourceForm({ defaultValues: { accessMode: "mention", apiKey: "", + graphqlAllowList: [], name: "Linear", }, resolver: zodResolver(LinearDataSourceFormSchema), }); const accessMode = form.watch("accessMode"); + const graphqlAllowList = form.watch( + "graphqlAllowList" + ) as LinearGraphqlAllowListItem[]; const mutation = useOptimisticAdd< { dataSource: { id: string } }, @@ -67,6 +82,7 @@ export function LinearDataSourceForm({ type: "linear", apiKey: data.apiKey, accessMode: data.accessMode, + graphqlAllowList: data.graphqlAllowList, } satisfies LinearApiKeyCredentials; return createDataSource({ @@ -92,6 +108,25 @@ export function LinearDataSourceForm({ shouldTouch: true, shouldValidate: true, }); + if (value !== "read_write") { + form.setValue( + "graphqlAllowList", + filterLinearGraphqlAllowListForAccessMode(value, graphqlAllowList), + { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + } + ); + } + } + + function handleGraphqlAllowListChange(value: LinearGraphqlAllowListItem[]) { + form.setValue("graphqlAllowList", value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); } return ( @@ -135,6 +170,18 @@ export function LinearDataSourceForm({ + {accessMode !== "mention" ? ( +
+ + +
+ ) : null} + ; +export const LINEAR_GRAPHQL_ALLOW_LIST = [ + "viewer", + "organization", + "teams", + "team", + "issues", + "issue", + "users", + "user", + "projects", + "project", + "labels", + "commentCreate", + "commentUpdate", + "issueCreate", + "issueUpdate", + "fileUpload", +] as const; + +export const LinearGraphqlAllowListItemSchema = z.enum( + LINEAR_GRAPHQL_ALLOW_LIST +); + +export type LinearGraphqlAllowListItem = z.infer< + typeof LinearGraphqlAllowListItemSchema +>; + +export const LinearGraphqlAllowListSchema = z + .array(LinearGraphqlAllowListItemSchema) + .max(LINEAR_GRAPHQL_ALLOW_LIST.length) + .transform((items) => [...new Set(items)]); + export const LinearApiKeyCredentialsSchema = z.object({ accessMode: LinearAccessModeSchema.optional(), apiKey: requiredOpaqueString("API key is required"), + graphqlAllowList: LinearGraphqlAllowListSchema.optional(), type: z.literal("linear"), }); @@ -601,6 +634,7 @@ export const LinearOAuthCredentialsSchema = z.object({ accessToken: requiredOpaqueString("Access token is required"), appUserId: optionalTrimmedString("App user ID is required"), expiresAt: optionalTrimmedString("Expiration timestamp is required"), + graphqlAllowList: LinearGraphqlAllowListSchema.optional(), linearOrganizationId: trimmedString("Linear organization ID is required"), linearOrganizationName: optionalTrimmedString( "Linear organization name is required" diff --git a/packages/db/src/source-providers.ts b/packages/db/src/source-providers.ts index b97ba911..d1e92221 100644 --- a/packages/db/src/source-providers.ts +++ b/packages/db/src/source-providers.ts @@ -1232,6 +1232,7 @@ export const SOURCE_PROVIDER_REGISTRY = { steps: [ "Create a Linear API key for the workspace you want OneQuery to access.", "Choose `mention`, `read`, or `read_write` in `credentials.accessMode`.", + "Optionally set `credentials.graphqlAllowList` to enable raw `graphql_request` for selected Linear GraphQL root fields.", "Use `mention` when OneQuery should keep the connection metadata but must not expose Linear issue reads or writes through Source API.", ], exampleInput: { @@ -1239,6 +1240,7 @@ export const SOURCE_PROVIDER_REGISTRY = { credentials: { accessMode: "read", apiKey: "lin_api_key", + graphqlAllowList: ["issue", "issues"], }, }, }, diff --git a/packages/server/src/source-api/adapters/linear.test.ts b/packages/server/src/source-api/adapters/linear.test.ts index 2ddfe922..5f422834 100644 --- a/packages/server/src/source-api/adapters/linear.test.ts +++ b/packages/server/src/source-api/adapters/linear.test.ts @@ -1,4 +1,7 @@ -import type { LinearCredentials } from "@onequery/db/server"; +import type { + LinearCredentials, + LinearGraphqlAllowListItem, +} from "@onequery/db/server"; import { describe, expect, it, vi } from "vitest"; import type { PreparedSourceConnection, SourceApiActorContext } from "../types"; @@ -17,10 +20,11 @@ const actor: SourceApiActorContext = { }; function createSource( - accessMode: LinearCredentials["accessMode"] + accessMode: LinearCredentials["accessMode"], + graphqlAllowList: LinearGraphqlAllowListItem[] = [] ): PreparedSourceConnection { return { - credentials: createLinearCredentials(accessMode), + credentials: createLinearCredentials(accessMode, graphqlAllowList), displayName: "Linear Workspace", id: "source_1", provider: "linear", @@ -29,11 +33,13 @@ function createSource( } function createLinearCredentials( - accessMode: LinearCredentials["accessMode"] + accessMode: LinearCredentials["accessMode"], + graphqlAllowList: LinearGraphqlAllowListItem[] = [] ): LinearCredentials { return { accessMode, accessToken: "lin_oauth_token", + graphqlAllowList, linearOrganizationId: "linear-org", type: "linear", }; @@ -99,6 +105,23 @@ describe("linear source api adapter", () => { ]); }); + it("exposes graphql_request only when GraphQL root fields are allow-listed", async () => { + const descriptor = await linearSourceApiAdapter.describe({ + actor, + source: createSource("read_write", ["commentUpdate", "issue"]), + }); + + expect(descriptor.operations.map((operation) => operation.name)).toContain( + "graphql_request" + ); + const graphqlOperation = descriptor.operations.find( + (operation) => operation.name === "graphql_request" + ); + expect(graphqlOperation?.notes).toContain( + "Allowed Linear GraphQL root fields: commentUpdate, issue" + ); + }); + it("normalizes list_workflow_states field patches into a team states query", async () => { const source = createSource("read"); const descriptor = await linearSourceApiAdapter.describe({ @@ -217,6 +240,127 @@ describe("linear source api adapter", () => { }); }); + it("normalizes allow-listed native Linear GraphQL requests", async () => { + const source = createSource("read_write", ["commentUpdate"]); + const descriptor = await linearSourceApiAdapter.describe({ + actor, + source, + }); + + const prepared = await linearSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "none" }, + fieldPatch: { + query: + "mutation RenameComment($id: String!, $input: CommentUpdateInput!) { commentUpdate(id: $id, input: $input) { success comment { id body } } }", + variables: { + id: "comment_123", + input: { body: "Updated body" }, + }, + }, + headers: [], + operation: "graphql_request", + }, + source, + }); + + if (prepared.kind !== "structured_request") { + throw new Error(`expected structured request, got ${prepared.kind}`); + } + + expect(prepared.request.query).toContain("commentUpdate"); + expect(prepared.request.variables).toEqual({ + id: "comment_123", + input: { body: "Updated body" }, + }); + }); + + it("does not treat GraphQL directives as root fields", async () => { + const source = createSource("read", ["issue"]); + const descriptor = await linearSourceApiAdapter.describe({ + actor, + source, + }); + + const prepared = await linearSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "none" }, + fieldPatch: { + query: + "query ReadIssue($id: String!, $show: Boolean!) { issue(id: $id) @include(if: $show) { id title } }", + variables: { id: "ENG-123", show: true }, + }, + headers: [], + operation: "graphql_request", + }, + source, + }); + + if (prepared.kind !== "structured_request") { + throw new Error(`expected structured request, got ${prepared.kind}`); + } + + expect(prepared.request.query).toContain("@include"); + }); + + it("rejects native Linear GraphQL root fields outside the allow list", async () => { + const source = createSource("read_write", ["issue"]); + const descriptor = await linearSourceApiAdapter.describe({ + actor, + source, + }); + + await expect( + linearSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "none" }, + fieldPatch: { + query: + "mutation RenameComment($id: String!, $input: CommentUpdateInput!) { commentUpdate(id: $id, input: $input) { success } }", + }, + headers: [], + operation: "graphql_request", + }, + source, + }) + ).rejects.toThrow( + 'Linear GraphQL root field "commentUpdate" is not in this connection' + ); + }); + + it("rejects native Linear GraphQL mutations on read-only connections", async () => { + const source = createSource("read", ["commentUpdate"]); + const descriptor = await linearSourceApiAdapter.describe({ + actor, + source, + }); + + await expect( + linearSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "none" }, + fieldPatch: { + query: + "mutation RenameComment($id: String!, $input: CommentUpdateInput!) { commentUpdate(id: $id, input: $input) { success } }", + }, + headers: [], + operation: "graphql_request", + }, + source, + }) + ).rejects.toThrow( + 'Linear operation "graphql_request" requires read_write access for mutations' + ); + }); + it("rejects update_issue without a state id", async () => { const source = createSource("read_write"); const descriptor = await linearSourceApiAdapter.describe({ diff --git a/packages/server/src/source-api/adapters/linear.ts b/packages/server/src/source-api/adapters/linear.ts index 86927b75..2a6ce225 100644 --- a/packages/server/src/source-api/adapters/linear.ts +++ b/packages/server/src/source-api/adapters/linear.ts @@ -1,6 +1,10 @@ import type { JsonObject, JsonValue } from "@bufbuild/protobuf"; import { getLinearAccessMode, isLinearCredentials } from "@onequery/db/server"; -import type { LinearAccessMode, LinearCredentials } from "@onequery/db/server"; +import type { + LinearAccessMode, + LinearCredentials, + LinearGraphqlAllowListItem, +} from "@onequery/db/server"; import { z } from "zod"; import { ProviderHttpClient } from "../../services/provider-http-client"; @@ -81,6 +85,14 @@ const UpdateIssueStateInputSchema = z }) .strict(); +const LinearGraphqlRequestInputSchema = z + .object({ + operationName: z.string().min(1).optional(), + query: z.string().min(1).max(50_000), + variables: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); + const LinearFileUploadRequestSchema = z .object({ contentType: z.string().regex(/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i), @@ -119,13 +131,22 @@ type LinearOperationName = | "create_issue" | "create_comment" | "upload_file" - | "update_issue"; + | "update_issue" + | "graphql_request"; type ListIssuesInput = z.infer; type ListWorkflowStatesInput = z.infer; type CreateIssueInput = z.infer; type CreateCommentInput = z.infer; type UpdateIssueStateInput = z.infer; +type LinearGraphqlRequestInput = z.infer; + +type LinearGraphqlOperationType = "query" | "mutation" | "subscription"; + +type LinearGraphqlOperationUse = { + operationType: LinearGraphqlOperationType; + rootFields: string[]; +}; type LinearGraphQlResponse = { body: SourceApiResponseBody; @@ -147,14 +168,19 @@ export function createLinearSourceApiAdapter( const credentials = requireLinearCredentials(source); const accessMode = getLinearAccessMode(credentials); const examples = buildLinearExamples(source.sourceKey, accessMode); + const graphqlAllowList = getLinearGraphqlAllowList(credentials); return { defaultPathOperation: accessMode === "mention" ? undefined : "list_issues", descriptorVersion: LINEAR_DESCRIPTOR_VERSION, examples, - notes: buildLinearNotes(accessMode), - operations: buildLinearOperations(accessMode, examples), + notes: buildLinearNotes(accessMode, graphqlAllowList), + operations: buildLinearOperations( + accessMode, + examples, + graphqlAllowList + ), source: { displayName: source.displayName, provider: source.provider, @@ -169,9 +195,10 @@ export function createLinearSourceApiAdapter( }); const credentials = requireLinearCredentials(source); const operationName = operation.name as LinearOperationName; + const accessMode = getLinearAccessMode(credentials); assertLinearOperationAllowed({ - accessMode: getLinearAccessMode(credentials), + accessMode, operation: operationName, }); const selector = normalizeLinearSelector({ @@ -187,8 +214,10 @@ export function createLinearSourceApiAdapter( methodOverride: request.methodOverride, }) : normalizeLinearGraphQlRequest({ + accessMode, body: request.body, fieldPatch: request.fieldPatch, + graphqlAllowList: getLinearGraphqlAllowList(credentials), headers: request.headers, methodOverride: request.methodOverride, operation: operationName, @@ -251,7 +280,8 @@ export const linearSourceApiAdapter = createLinearSourceApiAdapter(); function buildLinearOperations( accessMode: LinearAccessMode, - examples: readonly SourceApiExample[] + examples: readonly SourceApiExample[], + graphqlAllowList: readonly LinearGraphqlAllowListItem[] ): SourceApiOperation[] { if (accessMode === "mention") { return []; @@ -293,10 +323,20 @@ function buildLinearOperations( ]; if (accessMode !== "read_write") { - return readOperations; + return graphqlAllowList.length > 0 + ? [ + ...readOperations, + createLinearGraphqlRequestOperation({ + examples: examples.filter( + (example) => example.label === "GraphQL request" + ), + graphqlAllowList, + }), + ] + : readOperations; } - return [ + const writeOperations = [ ...readOperations, createLinearInputOperation({ description: @@ -327,6 +367,18 @@ function buildLinearOperations( examples: examples.filter((example) => example.label === "Upload file"), }), ]; + + return graphqlAllowList.length > 0 + ? [ + ...writeOperations, + createLinearGraphqlRequestOperation({ + examples: examples.filter( + (example) => example.label === "GraphQL request" + ), + graphqlAllowList, + }), + ] + : writeOperations; } function createLinearFileUploadOperation(input: { @@ -365,6 +417,43 @@ function createLinearFileUploadOperation(input: { }; } +function createLinearGraphqlRequestOperation(input: { + examples: readonly SourceApiExample[]; + graphqlAllowList: readonly LinearGraphqlAllowListItem[]; +}): SourceApiOperation { + return { + description: + "Execute a Linear GraphQL request whose top-level fields are present in this connection's GraphQL allow list.", + examples: input.examples, + fieldPolicy: { + acceptsInput: true, + allowsRawFields: true, + allowsTypedFields: true, + inputMode: "request_object", + mergePatches: false, + supportsArrayPaths: true, + supportsNestedPaths: true, + }, + headerPolicy: canonicalizeSourceApiHeaderPolicy({ + allowedRequestHeaders: [], + allowedResponseHeaders: LINEAR_ALLOWED_RESPONSE_HEADERS, + }), + kind: "structured_request", + methodPolicy: { + allowedMethods: ["POST"], + defaultMethod: "POST", + }, + name: "graphql_request", + notes: [ + `Allowed Linear GraphQL root fields: ${input.graphqlAllowList.join(", ")}`, + "Use a JSON object with query, optional variables, and optional operationName.", + ], + paginationPolicy: "none", + selectorKind: "none", + summary: "Execute an allow-listed Linear GraphQL request.", + }; +} + function createLinearReadOperation(input: { name: LinearOperationName; summary: string; @@ -468,6 +557,11 @@ function buildLinearExamples( description: "Fetch one Linear issue by identifier.", label: "Get issue", }, + { + command: `onequery api --source ${sourceKey} --op graphql_request -f 'query=query { viewer { id name } }'`, + description: "Execute an allow-listed Linear GraphQL query.", + label: "GraphQL request", + }, ]; if (accessMode !== "read_write") { @@ -497,10 +591,18 @@ function buildLinearExamples( "Upload a local file to Linear and return its private asset URL.", label: "Upload file", }, + { + command: `onequery api --source ${sourceKey} --op graphql_request -f 'query=mutation($id: String!, $input: CommentUpdateInput!) { commentUpdate(id: $id, input: $input) { success comment { id body } } }' -F 'variables.id=' -F 'variables.input.body=Updated body'`, + description: "Execute an allow-listed Linear GraphQL mutation.", + label: "GraphQL request", + }, ]; } -function buildLinearNotes(accessMode: LinearAccessMode): string[] { +function buildLinearNotes( + accessMode: LinearAccessMode, + graphqlAllowList: readonly LinearGraphqlAllowListItem[] +): string[] { if (accessMode === "mention") { return [ "This Linear connection is configured for @mentions only. Linear Source API operations are disabled.", @@ -508,12 +610,18 @@ function buildLinearNotes(accessMode: LinearAccessMode): string[] { } const notes = [ - "Linear Source API uses fixed GraphQL operations instead of accepting raw GraphQL from the caller.", + "Linear Source API exposes fixed operations by default and can also expose allow-listed raw GraphQL through graphql_request.", "Use list_teams first when you need a teamId for issue creation.", "Use list_workflow_states with an issue's teamId before changing the issue state.", "Use upload_file first, then embed its assetUrl in create_comment or create_issue Markdown.", ]; + if (graphqlAllowList.length > 0) { + notes.push( + `graphql_request is enabled for these Linear GraphQL root fields: ${graphqlAllowList.join(", ")}.` + ); + } + if (accessMode === "read") { notes.push( "This Linear connection is read-only; create_issue, create_comment, upload_file, and update_issue are disabled." @@ -547,6 +655,12 @@ function requireLinearCredentials( throw new Error("Linear source credentials are invalid"); } +function getLinearGraphqlAllowList( + credentials: LinearCredentials +): readonly LinearGraphqlAllowListItem[] { + return credentials.graphqlAllowList ?? []; +} + function assertLinearOperationAllowed(input: { accessMode: LinearAccessMode; operation: LinearOperationName; @@ -568,13 +682,27 @@ function assertLinearOperationAllowed(input: { } function normalizeLinearGraphQlRequest(input: { + accessMode: LinearAccessMode; operation: Exclude; selector?: string; fieldPatch?: JsonObject; + graphqlAllowList: readonly LinearGraphqlAllowListItem[]; methodOverride?: string; headers: readonly { name: string; value: string }[]; body: SourceApiRequestBody; }): { body: SourceApiRequestBody; request: JsonObject } { + if (input.operation === "graphql_request") { + return normalizeLinearNativeGraphqlRequest({ + accessMode: input.accessMode, + body: input.body, + fieldPatch: input.fieldPatch, + graphqlAllowList: input.graphqlAllowList, + headers: input.headers, + methodOverride: input.methodOverride, + operation: "graphql_request", + }); + } + assertNoLinearRequestExtras(input); return { body: { kind: "none" }, @@ -586,6 +714,75 @@ function normalizeLinearGraphQlRequest(input: { }; } +function normalizeLinearNativeGraphqlRequest(input: { + accessMode: LinearAccessMode; + operation: "graphql_request"; + fieldPatch?: JsonObject; + graphqlAllowList: readonly LinearGraphqlAllowListItem[]; + methodOverride?: string; + headers: readonly { name: string; value: string }[]; + body: SourceApiRequestBody; +}): { body: SourceApiRequestBody; request: JsonObject } { + if (input.methodOverride?.trim()) { + throw new SourceApiInvalidRequestError( + 'Linear operation "graphql_request" does not support method overrides' + ); + } + if (input.headers.length > 0) { + throw new SourceApiInvalidRequestError( + 'Linear operation "graphql_request" does not accept request headers' + ); + } + if (input.graphqlAllowList.length === 0) { + throw new SourceApiInvalidRequestError( + 'Linear operation "graphql_request" requires a non-empty GraphQL allow list' + ); + } + + const requestInput = parseLinearGraphqlRequestInput({ + body: input.body, + fieldPatch: input.fieldPatch, + }); + validateLinearGraphqlRequest({ + accessMode: input.accessMode, + allowList: input.graphqlAllowList, + query: requestInput.query, + }); + + return { + body: { kind: "none" }, + request: compactJsonObject({ + query: requestInput.query, + ...(requestInput.operationName + ? { operationName: requestInput.operationName } + : {}), + variables: requestInput.variables ?? {}, + }), + }; +} + +function parseLinearGraphqlRequestInput(input: { + fieldPatch?: JsonObject; + body: SourceApiRequestBody; +}): LinearGraphqlRequestInput { + if (input.body.kind !== "none" && input.fieldPatch) { + throw new SourceApiInvalidRequestError( + 'Linear operation "graphql_request" accepts either fieldPatch input or a JSON request body, not both' + ); + } + + const rawInput = + input.body.kind === "json" ? input.body.value : (input.fieldPatch ?? {}); + const parsed = LinearGraphqlRequestInputSchema.safeParse(rawInput); + if (!parsed.success) { + throw new SourceApiInvalidRequestError( + "Invalid Linear graphql_request input" + ); + } + + return parsed.data; +} + function normalizeLinearFileUploadRequest(input: { fieldPatch?: JsonObject; methodOverride?: string; @@ -740,8 +937,266 @@ function normalizeLinearSelector(input: { return selector; } +function validateLinearGraphqlRequest(input: { + accessMode: LinearAccessMode; + allowList: readonly LinearGraphqlAllowListItem[]; + query: string; +}) { + const operations = readLinearGraphqlOperationUses(input.query); + const allowed = new Set(input.allowList); + + for (const operation of operations) { + if (operation.operationType === "subscription") { + throw new SourceApiInvalidRequestError( + 'Linear operation "graphql_request" does not support subscriptions' + ); + } + if (input.accessMode === "read" && operation.operationType === "mutation") { + throw new SourceApiInvalidRequestError( + 'Linear operation "graphql_request" requires read_write access for mutations' + ); + } + + for (const field of operation.rootFields) { + if (!allowed.has(field)) { + throw new SourceApiInvalidRequestError( + `Linear GraphQL root field "${field}" is not in this connection's allow list` + ); + } + } + } +} + +function readLinearGraphqlOperationUses( + query: string +): LinearGraphqlOperationUse[] { + const tokens = tokenizeGraphqlDocument(query); + const operations: LinearGraphqlOperationUse[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === "fragment") { + const fragmentSelection = findNextSelectionSet(tokens, index + 1); + if (fragmentSelection) { + index = fragmentSelection.endIndex; + } + continue; + } + + if (token === "{") { + const selection = readLinearGraphqlSelection(tokens, index); + operations.push({ + operationType: "query", + rootFields: selection.rootFields, + }); + index = selection.endIndex; + continue; + } + + if (isLinearGraphqlOperationType(token)) { + const selection = findNextSelectionSet(tokens, index + 1); + if (!selection) { + throw new SourceApiInvalidRequestError( + "Invalid Linear GraphQL document: operation is missing a selection set" + ); + } + operations.push({ + operationType: token, + rootFields: selection.rootFields, + }); + index = selection.endIndex; + } + } + + if (operations.length === 0) { + throw new SourceApiInvalidRequestError( + "Invalid Linear GraphQL document: no operation found" + ); + } + + return operations; +} + +function tokenizeGraphqlDocument(query: string): string[] { + const tokens: string[] = []; + let index = 0; + + while (index < query.length) { + const char = query[index]; + if (!char) { + break; + } + if (/\s|,/u.test(char)) { + index += 1; + continue; + } + if (char === "#") { + index += 1; + while (index < query.length && query[index] !== "\n") { + index += 1; + } + continue; + } + if (char === '"') { + index = skipGraphqlString(query, index); + continue; + } + if (char === "." && query.slice(index, index + 3) === "...") { + tokens.push("..."); + index += 3; + continue; + } + if (/[_A-Za-z]/u.test(char)) { + const start = index; + index += 1; + while (index < query.length && /[_0-9A-Za-z]/u.test(query[index] ?? "")) { + index += 1; + } + tokens.push(query.slice(start, index)); + continue; + } + if ("{}()[]:@$!=".includes(char)) { + tokens.push(char); + } + index += 1; + } + + return tokens; +} + +function skipGraphqlString(query: string, start: number): number { + if (query.slice(start, start + 3) === '"""') { + const end = query.indexOf('"""', start + 3); + return end === -1 ? query.length : end + 3; + } + + let index = start + 1; + while (index < query.length) { + const char = query[index]; + if (char === "\\") { + index += 2; + continue; + } + if (char === '"') { + return index + 1; + } + index += 1; + } + + return query.length; +} + +function findNextSelectionSet( + tokens: readonly string[], + startIndex: number +): { rootFields: string[]; endIndex: number } | null { + let parenDepth = 0; + let bracketDepth = 0; + + for (let index = startIndex; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === "(") { + parenDepth += 1; + continue; + } + if (token === ")") { + parenDepth = Math.max(0, parenDepth - 1); + continue; + } + if (token === "[") { + bracketDepth += 1; + continue; + } + if (token === "]") { + bracketDepth = Math.max(0, bracketDepth - 1); + continue; + } + if (token === "{" && parenDepth === 0 && bracketDepth === 0) { + return readLinearGraphqlSelection(tokens, index); + } + } + + return null; +} + +function readLinearGraphqlSelection( + tokens: readonly string[], + startIndex: number +): { rootFields: string[]; endIndex: number } { + const rootFields = new Set(); + let selectionDepth = 1; + let parenDepth = 0; + let bracketDepth = 0; + + for (let index = startIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === "(") { + parenDepth += 1; + continue; + } + if (token === ")") { + parenDepth = Math.max(0, parenDepth - 1); + continue; + } + if (token === "[") { + bracketDepth += 1; + continue; + } + if (token === "]") { + bracketDepth = Math.max(0, bracketDepth - 1); + continue; + } + if (token === "{" && parenDepth === 0 && bracketDepth === 0) { + selectionDepth += 1; + continue; + } + if (token === "}" && parenDepth === 0 && bracketDepth === 0) { + selectionDepth -= 1; + if (selectionDepth === 0) { + return { endIndex: index, rootFields: [...rootFields] }; + } + continue; + } + if (selectionDepth !== 1 || parenDepth !== 0 || bracketDepth !== 0) { + continue; + } + if (token === "...") { + throw new SourceApiInvalidRequestError( + "Linear graphql_request does not support top-level fragment spreads" + ); + } + if (token === "@") { + index += 1; + continue; + } + if (!isGraphqlName(token) || token === "on") { + continue; + } + + const next = tokens[index + 1]; + const aliasedField = next === ":" ? tokens[index + 2] : undefined; + rootFields.add( + aliasedField && isGraphqlName(aliasedField) ? aliasedField : token + ); + } + + throw new SourceApiInvalidRequestError( + "Invalid Linear GraphQL document: unterminated selection set" + ); +} + +function isLinearGraphqlOperationType( + token: string | undefined +): token is LinearGraphqlOperationType { + return token === "query" || token === "mutation" || token === "subscription"; +} + +function isGraphqlName(token: string | undefined): token is string { + return typeof token === "string" && /^[_A-Za-z][_0-9A-Za-z]*$/u.test(token); +} + function buildLinearGraphQlRequest(input: { - operation: LinearOperationName; + operation: Exclude; selector?: string; fieldPatch?: JsonObject; }): JsonObject { @@ -941,10 +1396,6 @@ function buildLinearGraphQlRequest(input: { }, }; } - case "upload_file": - throw new SourceApiInvalidRequestError( - 'Linear operation "upload_file" requires request body input' - ); } } diff --git a/packages/ui/src/data-sources/linear-access-mode.tsx b/packages/ui/src/data-sources/linear-access-mode.tsx index 4ae88044..dbe57b1b 100644 --- a/packages/ui/src/data-sources/linear-access-mode.tsx +++ b/packages/ui/src/data-sources/linear-access-mode.tsx @@ -1,9 +1,16 @@ -import { LINEAR_ACCESS_MODES } from "@onequery/db/credentials"; -import type { LinearAccessMode } from "@onequery/db/credentials"; +import { + LINEAR_ACCESS_MODES, + LINEAR_GRAPHQL_ALLOW_LIST, +} from "@onequery/db/credentials"; +import type { + LinearAccessMode, + LinearGraphqlAllowListItem, +} from "@onequery/db/credentials"; import { IconAt, IconEye, IconPencil } from "@tabler/icons-react"; import type { ReactNode } from "react"; import { Button } from "../components/ui/button"; +import { Checkbox } from "../components/ui/checkbox"; const LINEAR_ACCESS_MODE_DETAILS: Record< LinearAccessMode, @@ -19,7 +26,49 @@ export const LINEAR_ACCESS_MODE_OPTIONS = LINEAR_ACCESS_MODES.map((value) => ({ ...LINEAR_ACCESS_MODE_DETAILS[value], })); -export type { LinearAccessMode }; +const LINEAR_GRAPHQL_ALLOW_LIST_DETAILS: Record< + LinearGraphqlAllowListItem, + { label: string; mode: "read" | "write" } +> = { + commentCreate: { label: "commentCreate", mode: "write" }, + commentUpdate: { label: "commentUpdate", mode: "write" }, + fileUpload: { label: "fileUpload", mode: "write" }, + issue: { label: "issue", mode: "read" }, + issueCreate: { label: "issueCreate", mode: "write" }, + issueUpdate: { label: "issueUpdate", mode: "write" }, + issues: { label: "issues", mode: "read" }, + labels: { label: "labels", mode: "read" }, + organization: { label: "organization", mode: "read" }, + project: { label: "project", mode: "read" }, + projects: { label: "projects", mode: "read" }, + team: { label: "team", mode: "read" }, + teams: { label: "teams", mode: "read" }, + user: { label: "user", mode: "read" }, + users: { label: "users", mode: "read" }, + viewer: { label: "viewer", mode: "read" }, +}; + +export const LINEAR_GRAPHQL_ALLOW_LIST_OPTIONS = LINEAR_GRAPHQL_ALLOW_LIST.map( + (value) => ({ + value, + ...LINEAR_GRAPHQL_ALLOW_LIST_DETAILS[value], + }) +); + +export type { LinearAccessMode, LinearGraphqlAllowListItem }; + +export function filterLinearGraphqlAllowListForAccessMode( + accessMode: LinearAccessMode, + value: readonly LinearGraphqlAllowListItem[] +): LinearGraphqlAllowListItem[] { + if (accessMode === "read_write") { + return [...value]; + } + + return value.filter( + (item) => LINEAR_GRAPHQL_ALLOW_LIST_DETAILS[item].mode === "read" + ); +} export function getLinearAccessModeLabel( accessMode: LinearAccessMode | undefined @@ -64,3 +113,52 @@ export function LinearAccessModeSelector({ ); } + +export function LinearGraphqlAllowListSelector({ + disabled, + accessMode, + value, + onChange, +}: { + disabled?: boolean; + accessMode: LinearAccessMode; + value: readonly LinearGraphqlAllowListItem[]; + onChange: (value: LinearGraphqlAllowListItem[]) => void; +}) { + const selected = new Set(value); + const writeEnabled = accessMode === "read_write"; + + function toggle(item: LinearGraphqlAllowListItem) { + const next = new Set(selected); + if (next.has(item)) { + next.delete(item); + } else { + next.add(item); + } + onChange( + LINEAR_GRAPHQL_ALLOW_LIST.filter((candidate) => next.has(candidate)) + ); + } + + return ( +
+ {LINEAR_GRAPHQL_ALLOW_LIST_OPTIONS.map((option) => { + const optionDisabled = + disabled || (option.mode === "write" && !writeEnabled); + return ( + + ); + })} +
+ ); +} From 4565eabecd96fc5d44a23cf0f0d573dd9bccf915 Mon Sep 17 00:00:00 2001 From: Jaeyoun Nam Date: Fri, 17 Jul 2026 07:55:14 -0700 Subject: [PATCH 2/3] style(linear): format graphql source api --- packages/server/src/source-api/adapters/linear.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/src/source-api/adapters/linear.ts b/packages/server/src/source-api/adapters/linear.ts index 2a6ce225..46323810 100644 --- a/packages/server/src/source-api/adapters/linear.ts +++ b/packages/server/src/source-api/adapters/linear.ts @@ -139,7 +139,9 @@ type ListWorkflowStatesInput = z.infer; type CreateIssueInput = z.infer; type CreateCommentInput = z.infer; type UpdateIssueStateInput = z.infer; -type LinearGraphqlRequestInput = z.infer; +type LinearGraphqlRequestInput = z.infer< + typeof LinearGraphqlRequestInputSchema +>; type LinearGraphqlOperationType = "query" | "mutation" | "subscription"; From cc5e9b0a47292d1c01959371aca461996bf42daa Mon Sep 17 00:00:00 2001 From: Jaeyoun Nam Date: Fri, 17 Jul 2026 07:57:54 -0700 Subject: [PATCH 3/3] fix(linear): align dashboard form schema types --- .../src/features/data-sources/forms/linear-data-source-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/features/data-sources/forms/linear-data-source-form.tsx b/apps/dashboard/src/features/data-sources/forms/linear-data-source-form.tsx index 6d0fc314..8ab56552 100644 --- a/apps/dashboard/src/features/data-sources/forms/linear-data-source-form.tsx +++ b/apps/dashboard/src/features/data-sources/forms/linear-data-source-form.tsx @@ -32,7 +32,7 @@ import { applyDataSourceNameConflictError } from "./data-source-errors"; const LinearDataSourceFormSchema = z.object({ accessMode: LinearAccessModeSchema, apiKey: z.string().min(1, "API key is required"), - graphqlAllowList: z.array(LinearGraphqlAllowListItemSchema).default([]), + graphqlAllowList: z.array(LinearGraphqlAllowListItemSchema), name: z.string().min(1, "Name is required"), });