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..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
@@ -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),
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..46323810 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,24 @@ 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<
+ typeof LinearGraphqlRequestInputSchema
+>;
+
+type LinearGraphqlOperationType = "query" | "mutation" | "subscription";
+
+type LinearGraphqlOperationUse = {
+ operationType: LinearGraphqlOperationType;
+ rootFields: string[];
+};
type LinearGraphQlResponse = {
body: SourceApiResponseBody;
@@ -147,14 +170,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 +197,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 +216,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 +282,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 +325,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 +369,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 +419,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 +559,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 +593,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 +612,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 +657,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 +684,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 +716,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 +939,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 +1398,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 (
+
+ );
+ })}
+
+ );
+}