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
Expand Up @@ -518,7 +518,13 @@ export default function EditIntegrationPage() {
<input
id="name"
type="text"
{...register("name", { required: "Name is required" })}
{...register("name", {
required: "Name is required",
pattern: {
value: /^[a-z0-9_-]+$/,
message: "Name can only contain lowercase letters, numbers, hyphens, and underscores",
},
})}
className="input input-bordered w-full text-base"
/>
{errors.name && <p className="text-sm text-error">{errors.name.message}</p>}
Expand Down
10 changes: 8 additions & 2 deletions admin-panel/app/routes/_authRequired+/integrations.new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,14 @@ export default function NewIntegrationPage() {
<input
id="name"
type="text"
{...register("name", { required: "Name is required" })}
placeholder="e.g., Production API"
{...register("name", {
required: "Name is required",
pattern: {
value: /^[a-z0-9_-]+$/,
message: "Name can only contain lowercase letters, numbers, hyphens, and underscores",
},
})}
placeholder="e.g., production-api"
className={inputClasses}
/>
{errors.name && <p className="text-sm text-error">{errors.name.message}</p>}
Expand Down
1 change: 1 addition & 0 deletions api/db/migrations-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export const migrations = {
"20251127_pat_link": import("../migrations/20251127_pat_link.ts"),
"20251127_visibility": import("../migrations/20251127_visibility.ts"),
"20251205_0633_mcp_plugin_oauth": import("../migrations/20251205_0633_mcp_plugin_oauth.ts"),
"20251210_integration_name_unique": import("../migrations/20251210_integration_name_unique.ts"),
};
34 changes: 34 additions & 0 deletions api/migrations/20251210_integration_name_unique.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Kysely } from "kysely";

export const up = async (db: Kysely<unknown>): Promise<void> => {
// Drop old constraint (org + provider + name)
await db.schema
.alterTable("integration")
.dropConstraint("integration_org_provider_name_unique")
.execute();

// Add new constraint (org + name only) - names must be unique per organization
await db.schema
.alterTable("integration")
.addUniqueConstraint("integration_org_name_unique", [
"organizationId",
"name",
])
.execute();
};

export const down = async (db: Kysely<unknown>): Promise<void> => {
await db.schema
.alterTable("integration")
.dropConstraint("integration_org_name_unique")
.execute();

await db.schema
.alterTable("integration")
.addUniqueConstraint("integration_org_provider_name_unique", [
"organizationId",
"provider",
"name",
])
.execute();
};
18 changes: 17 additions & 1 deletion api/models/integration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { nanoid } from "nanoid";
import { sql } from "kysely";
import { db } from "../db/index.ts";
import type { IntegrationTable } from "../db/schema.ts";
import { validateIntegrationName } from "../validation/integration-name";
import type {
AuthStrategy,
IntegrationProvider,
Expand Down Expand Up @@ -388,6 +390,11 @@ export const createIntegration = async (
throw new Error(`Invalid provider: ${params.provider}`);
}

const nameValidation = validateIntegrationName(params.name);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}

validateIntegrationConfig(params.authConfig);
const id = nanoid();
const now = new Date().toISOString();
Expand Down Expand Up @@ -493,6 +500,10 @@ export const updateIntegration = async (
};

if (params.name !== undefined) {
const nameValidation = validateIntegrationName(params.name);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}
updateValues.name = params.name;
}

Expand Down Expand Up @@ -542,6 +553,11 @@ export type CreateDynamicIntegrationRequest = {
export const createDynamicIntegration = async (
params: CreateDynamicIntegrationRequest
): Promise<IntegrationWithConfig> => {
const nameValidation = validateIntegrationName(params.name);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}

validateMcpConfig(params.authConfig);

const id = nanoid();
Expand Down Expand Up @@ -634,7 +650,7 @@ export const getIntegrationByName = async (
const row = await db
.selectFrom("integration")
.selectAll()
.where("name", "=", name)
.where(sql`LOWER(name)`, "=", name.toLowerCase())
.where("organizationId", "=", organizationId)
.where("enabled", "=", true)
.executeTakeFirst();
Expand Down
96 changes: 96 additions & 0 deletions api/validation/integration-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { expect } from "@std/expect";
import { describe, it } from "@std/testing/bdd";
import { validateIntegrationName, INTEGRATION_NAME_PATTERN } from "./integration-name";

describe("Integration Name Validation", () => {
describe("INTEGRATION_NAME_PATTERN", () => {
it("matches valid lowercase names", () => {
expect(INTEGRATION_NAME_PATTERN.test("github")).toBe(true);
expect(INTEGRATION_NAME_PATTERN.test("myapi")).toBe(true);
});

it("matches names with hyphens", () => {
expect(INTEGRATION_NAME_PATTERN.test("my-api")).toBe(true);
expect(INTEGRATION_NAME_PATTERN.test("my-github-api")).toBe(true);
});

it("matches names with underscores", () => {
expect(INTEGRATION_NAME_PATTERN.test("my_api")).toBe(true);
expect(INTEGRATION_NAME_PATTERN.test("my_github_api")).toBe(true);
});

it("matches names with numbers", () => {
expect(INTEGRATION_NAME_PATTERN.test("api123")).toBe(true);
expect(INTEGRATION_NAME_PATTERN.test("api-v2")).toBe(true);
expect(INTEGRATION_NAME_PATTERN.test("test123api")).toBe(true);
});

it("matches combined valid characters", () => {
expect(INTEGRATION_NAME_PATTERN.test("my_github-api_v2")).toBe(true);
expect(INTEGRATION_NAME_PATTERN.test("test-api_123")).toBe(true);
});

it("rejects uppercase letters", () => {
expect(INTEGRATION_NAME_PATTERN.test("GitHub")).toBe(false);
expect(INTEGRATION_NAME_PATTERN.test("MyAPI")).toBe(false);
expect(INTEGRATION_NAME_PATTERN.test("myAPI")).toBe(false);
});

it("rejects spaces", () => {
expect(INTEGRATION_NAME_PATTERN.test("my api")).toBe(false);
expect(INTEGRATION_NAME_PATTERN.test("my github api")).toBe(false);
});

it("rejects special characters", () => {
expect(INTEGRATION_NAME_PATTERN.test("my@api")).toBe(false);
expect(INTEGRATION_NAME_PATTERN.test("my.api")).toBe(false);
expect(INTEGRATION_NAME_PATTERN.test("my!api")).toBe(false);
expect(INTEGRATION_NAME_PATTERN.test("my#api")).toBe(false);
});
});

describe("validateIntegrationName", () => {
it("accepts valid lowercase names", () => {
expect(validateIntegrationName("github").valid).toBe(true);
expect(validateIntegrationName("my-api").valid).toBe(true);
expect(validateIntegrationName("api_v2").valid).toBe(true);
expect(validateIntegrationName("test-api-123").valid).toBe(true);
});

it("rejects empty names", () => {
const result = validateIntegrationName("");
expect(result.valid).toBe(false);
expect(result.error).toContain("required");
});

it("rejects whitespace-only names", () => {
const result = validateIntegrationName(" ");
expect(result.valid).toBe(false);
expect(result.error).toContain("required");
});

it("rejects names with uppercase letters", () => {
const result = validateIntegrationName("GitHub");
expect(result.valid).toBe(false);
expect(result.error).toContain("lowercase");
});

it("rejects names with spaces", () => {
const result = validateIntegrationName("my api");
expect(result.valid).toBe(false);
expect(result.error).toContain("lowercase");
});

it("rejects names with special characters", () => {
const result = validateIntegrationName("my@api");
expect(result.valid).toBe(false);
expect(result.error).toContain("lowercase");
});

it("returns undefined error when valid", () => {
const result = validateIntegrationName("valid-name");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
});
});
22 changes: 22 additions & 0 deletions api/validation/integration-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Pattern: lowercase letters, numbers, hyphens, underscores only
export const INTEGRATION_NAME_PATTERN = /^[a-z0-9_-]+$/;

export interface IntegrationNameValidationResult {
valid: boolean;
error?: string;
}

export const validateIntegrationName = (name: string): IntegrationNameValidationResult => {
if (!name || !name.trim()) {
return { valid: false, error: "Integration name is required" };
}

if (!INTEGRATION_NAME_PATTERN.test(name)) {
return {
valid: false,
error: "Integration name can only contain lowercase letters (a-z), numbers (0-9), hyphens (-), and underscores (_)",
};
}

return { valid: true };
};
Loading