Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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"),
});

Expand All @@ -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 } },
Expand All @@ -67,6 +82,7 @@ export function LinearDataSourceForm({
type: "linear",
apiKey: data.apiKey,
accessMode: data.accessMode,
graphqlAllowList: data.graphqlAllowList,
} satisfies LinearApiKeyCredentials;

return createDataSource({
Expand All @@ -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 (
Expand Down Expand Up @@ -135,6 +170,18 @@ export function LinearDataSourceForm({
<FormFieldError message={form.formState.errors.accessMode?.message} />
</div>

{accessMode !== "mention" ? (
<div className="space-y-2">
<Label>GraphQL allow list</Label>
<LinearGraphqlAllowListSelector
accessMode={accessMode}
disabled={mutation.isPending}
value={graphqlAllowList}
onChange={handleGraphqlAllowListChange}
/>
</div>
) : null}

<FormSubmitButton
idleLabel="Connect Linear"
isPending={mutation.isPending}
Expand Down
34 changes: 34 additions & 0 deletions packages/db/src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,9 +586,42 @@ export const LinearAccessModeSchema = z.enum(LINEAR_ACCESS_MODES);

export type LinearAccessMode = z.infer<typeof LinearAccessModeSchema>;

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"),
});

Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/source-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1232,13 +1232,15 @@ 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: {
sourceKey: "linear_main",
credentials: {
accessMode: "read",
apiKey: "lin_api_key",
graphqlAllowList: ["issue", "issues"],
},
},
},
Expand Down
152 changes: 148 additions & 4 deletions packages/server/src/source-api/adapters/linear.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand All @@ -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",
};
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
Loading