diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index d4411fb9f3b..817312121a0 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as PlatformError from "effect/PlatformError"; +import * as NodePath from "node:path"; import * as ServerConfig from "../config.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -225,6 +226,41 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("encrypts secret contents at rest", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const plaintext = new TextEncoder().encode("super-secret-token"); + + yield* secretStore.set("session-signing-key", plaintext); + + const persisted = yield* fileSystem.readFile( + NodePath.join(serverConfig.secretsDir, "session-signing-key.bin"), + ); + + assert.notDeepEqual(Array.from(persisted), Array.from(plaintext)); + const roundTrip = yield* secretStore.get("session-signing-key"); + assert.deepEqual(Array.from(Option.getOrThrow(roundTrip)), Array.from(plaintext)); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("returns a read error when the secret payload cannot be decrypted", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const secretPath = NodePath.join(serverConfig.secretsDir, "session-signing-key.bin"); + + yield* fileSystem.writeFile(secretPath, Uint8Array.from([0, 1, 2, 3])); + + const error = yield* Effect.flip(secretStore.get("session-signing-key")); + + assert.instanceOf(error, ServerSecretStore.SecretStoreReadError); + assert.include(error.message, "Failed to read secret session-signing-key."); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + it.effect("propagates read failures other than missing-file errors", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 5e9890c1ea2..0f35e13d09a 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -8,6 +8,7 @@ import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as NodeCrypto from "node:crypto"; import * as ServerConfig from "../config.ts"; @@ -16,6 +17,41 @@ const secretStoreErrorContext = { cause: Schema.Defect(), }; +const SECRET_STORE_KEY_FILENAME = ".secret-store-key"; +const SECRET_STORE_KEY_BYTES = 32; +const SECRET_STORE_IV_BYTES = 12; +const SECRET_STORE_AUTH_TAG_BYTES = 16; +const SECRET_STORE_MAGIC = Buffer.from("T3S1"); + +function isEncryptedSecret(bytes: Uint8Array): boolean { + return Buffer.from(bytes.subarray(0, SECRET_STORE_MAGIC.length)).equals(SECRET_STORE_MAGIC); +} + +function encryptSecretBytes(key: Uint8Array, plaintext: Uint8Array): Uint8Array { + const iv = NodeCrypto.randomBytes(SECRET_STORE_IV_BYTES); + const cipher = NodeCrypto.createCipheriv("aes-256-gcm", Buffer.from(key), iv); + const ciphertext = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Uint8Array.from(Buffer.concat([SECRET_STORE_MAGIC, iv, authTag, ciphertext])); +} + +function decryptSecretBytes(key: Uint8Array, payload: Uint8Array): Uint8Array { + if (!isEncryptedSecret(payload)) { + return Uint8Array.from(payload); + } + + const bytes = Buffer.from(payload); + const ivStart = SECRET_STORE_MAGIC.length; + const authTagStart = ivStart + SECRET_STORE_IV_BYTES; + const ciphertextStart = authTagStart + SECRET_STORE_AUTH_TAG_BYTES; + const iv = bytes.subarray(ivStart, authTagStart); + const authTag = bytes.subarray(authTagStart, ciphertextStart); + const ciphertext = bytes.subarray(ciphertextStart); + const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", Buffer.from(key), iv); + decipher.setAuthTag(authTag); + return Uint8Array.from(Buffer.concat([decipher.update(ciphertext), decipher.final()])); +} + export class SecretStoreSecureError extends Schema.TaggedErrorClass()( "SecretStoreSecureError", { @@ -149,6 +185,84 @@ export class ServerSecretStore extends Context.Service< } >()("t3/auth/ServerSecretStore") {} +const loadOrCreateEncryptionKey = Effect.fn("ServerSecretStore.loadOrCreateEncryptionKey")( + function* (keyPath: string) { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + + const readKey = () => + fileSystem.readFile(keyPath).pipe( + Effect.map((bytes) => Uint8Array.from(bytes)), + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.fail(cause) + : Effect.fail( + new SecretStoreReadError({ + resource: `secret store key ${keyPath}`, + cause, + }), + ), + ), + ); + + const createKey = () => + crypto.randomBytes(SECRET_STORE_KEY_BYTES).pipe( + Effect.mapError( + (cause) => + new SecretStoreRandomGenerationError({ + resource: `secret store key ${keyPath}`, + cause, + }), + ), + Effect.flatMap((generated) => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(keyPath, { + flag: "wx", + mode: 0o600, + }); + yield* file.writeAll(generated); + yield* file.sync; + yield* fileSystem.chmod(keyPath, 0o600); + return Uint8Array.from(generated); + }), + ).pipe( + Effect.catch((cause) => + cause.reason._tag === "AlreadyExists" + ? readKey() + : Effect.fail( + new SecretStorePersistError({ + resource: `secret store key ${keyPath}`, + cause, + }), + ), + ), + ), + ), + ); + + return yield* readKey().pipe( + Effect.flatMap((key) => + key.length === SECRET_STORE_KEY_BYTES + ? Effect.succeed(key) + : Effect.fail( + new SecretStoreSecureError({ + resource: `secret store key ${keyPath}`, + cause: new Error( + `Expected ${SECRET_STORE_KEY_BYTES} key bytes, received ${key.length}.`, + ), + }), + ), + ), + Effect.catchIf( + (cause): cause is PlatformError.PlatformError => + Predicate.isTagged(cause, "PlatformError") && cause.reason._tag === "NotFound", + () => createKey(), + ), + ); + }, +); + export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; @@ -167,79 +281,114 @@ export const make = Effect.gen(function* () { ); const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); + const encryptionKeyPath = path.join(serverConfig.secretsDir, SECRET_STORE_KEY_FILENAME); + const encryptionKey = yield* loadOrCreateEncryptionKey(encryptionKeyPath).pipe( + Effect.mapError( + (cause) => + new SecretStoreSecureError({ + resource: `secret store key ${encryptionKeyPath}`, + cause, + }), + ), + ); const get: ServerSecretStore["Service"]["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( - Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), - Effect.catch((cause) => - cause.reason._tag === "NotFound" - ? Effect.succeed(Option.none()) - : Effect.fail( - new SecretStoreReadError({ - resource: `secret ${name}`, - cause, - }), - ), + Effect.flatMap((bytes) => + Effect.try({ + try: () => Option.some(decryptSecretBytes(encryptionKey, bytes)), + catch: (cause) => + new SecretStoreReadError({ + resource: `secret ${name}`, + cause, + }), + }), + ), + Effect.catchIf( + (cause) => cause.reason?._tag === "NotFound", + () => Effect.succeed(Option.none()), ), Effect.withSpan("ServerSecretStore.get"), ); const set: ServerSecretStore["Service"]["set"] = (name, value) => { const secretPath = resolveSecretPath(name); - return crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new SecretStoreTemporaryPathError({ - resource: `secret ${name}`, - cause, - }), - ), - Effect.flatMap((uuid) => { - const tempPath = `${secretPath}.${uuid}.tmp`; - return Effect.gen(function* () { - yield* fileSystem.writeFile(tempPath, value); - yield* fileSystem.chmod(tempPath, 0o600); - yield* fileSystem.rename(tempPath, secretPath); - yield* fileSystem.chmod(secretPath, 0o600); - }).pipe( - Effect.catch((cause) => - fileSystem.remove(tempPath).pipe( - Effect.ignore, - Effect.flatMap(() => - Effect.fail( - new SecretStorePersistError({ - resource: `secret ${name}`, - cause, - }), + return Effect.try({ + try: () => encryptSecretBytes(encryptionKey, value), + catch: (cause) => + new SecretStoreEncodeError({ + resource: `secret ${name}`, + cause, + }), + }).pipe( + Effect.flatMap((encryptedValue) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new SecretStoreTemporaryPathError({ + resource: `secret ${name}`, + cause, + }), + ), + Effect.flatMap((uuid) => { + const tempPath = `${secretPath}.${uuid}.tmp`; + return Effect.gen(function* () { + yield* fileSystem.writeFile(tempPath, encryptedValue); + yield* fileSystem.chmod(tempPath, 0o600); + yield* fileSystem.rename(tempPath, secretPath); + yield* fileSystem.chmod(secretPath, 0o600); + }).pipe( + Effect.catch((cause) => + fileSystem.remove(tempPath).pipe( + Effect.ignore, + Effect.flatMap(() => + Effect.fail( + new SecretStorePersistError({ + resource: `secret ${name}`, + cause, + }), + ), + ), ), ), - ), - ), - ); - }), + ); + }), + ), + ), Effect.withSpan("ServerSecretStore.set"), ); }; const create: ServerSecretStore["Service"]["create"] = (name, value) => { const secretPath = resolveSecretPath(name); - return Effect.scoped( - Effect.gen(function* () { - const file = yield* fileSystem.open(secretPath, { - flag: "wx", - mode: 0o600, - }); - yield* file.writeAll(value); - yield* file.sync; - yield* fileSystem.chmod(secretPath, 0o600); - }), - ).pipe( - Effect.mapError( - (cause) => - new SecretStorePersistError({ - resource: `secret ${name}`, - cause, + return Effect.try({ + try: () => encryptSecretBytes(encryptionKey, value), + catch: (cause) => + new SecretStoreEncodeError({ + resource: `secret ${name}`, + cause, + }), + }).pipe( + Effect.flatMap((encryptedValue) => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(secretPath, { + flag: "wx", + mode: 0o600, + }); + yield* file.writeAll(encryptedValue); + yield* file.sync; + yield* fileSystem.chmod(secretPath, 0o600); }), + ).pipe( + Effect.mapError( + (cause) => + new SecretStorePersistError({ + resource: `secret ${name}`, + cause, + }), + ), + ), ), ); }; diff --git a/apps/server/src/integrations.test.ts b/apps/server/src/integrations.test.ts new file mode 100644 index 00000000000..7fd38c69c40 --- /dev/null +++ b/apps/server/src/integrations.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { type IntegrationAccountTokenValidationInput } from "@t3tools/contracts"; + +import { testIntegrationToken } from "./integrations.ts"; + +function makeHttpClient(response: Response, onRequest?: (request: any) => void) { + return HttpClient.make((request) => + Effect.sync(() => { + onRequest?.(request); + return HttpClientResponse.fromWeb(request, response); + }), + ); +} + +function provideHttpClient( + input: T, + response: Response, + onRequest?: (request: any) => void, +) { + return testIntegrationToken(input).pipe( + Effect.provideService(HttpClient.HttpClient, makeHttpClient(response, onRequest)), + ); +} + +describe("testIntegrationToken", () => { + it.effect("verifies GitHub user tokens", () => + Effect.gen(function* () { + const result = yield* provideHttpClient( + { kind: "github", apiKey: "ghp_test" }, + Response.json({ login: "octocat", name: "Monalisa Octocat", email: null }, { status: 200 }), + ); + + expect(result.accountLabel).toBe("Monalisa Octocat"); + }), + ); + + it.effect("verifies GitLab user tokens", () => + Effect.gen(function* () { + const result = yield* provideHttpClient( + { kind: "gitlab", apiKey: "glpat_test" }, + Response.json({ username: "octocat", name: "Monalisa Octocat" }, { status: 200 }), + ); + + expect(result.accountLabel).toBe("Monalisa Octocat"); + }), + ); + + it.effect("verifies Jira tokens against the configured base URL", () => + Effect.gen(function* () { + const result = yield* provideHttpClient( + { + kind: "jira", + accountName: "jira@example.test", + baseUrl: "https://jira.example.test", + apiKey: "jira_token", + }, + Response.json( + { displayName: "Jira User", emailAddress: "jira@example.test" }, + { status: 200 }, + ), + (request) => { + expect(request.headers.authorization).toBe( + `Basic ${Buffer.from("jira@example.test:jira_token").toString("base64")}`, + ); + }, + ); + + expect(result.accountLabel).toBe("Jira User"); + }), + ); + + it.effect("verifies Linear API keys", () => + Effect.gen(function* () { + const result = yield* provideHttpClient( + { kind: "linear", apiKey: "lin_test" }, + Response.json({ data: { viewer: { name: "Linear User", email: null } } }, { status: 200 }), + ); + + expect(result.accountLabel).toBe("Linear User"); + }), + ); +}); diff --git a/apps/server/src/integrations.ts b/apps/server/src/integrations.ts new file mode 100644 index 00000000000..9903a3c0981 --- /dev/null +++ b/apps/server/src/integrations.ts @@ -0,0 +1,199 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + INTEGRATION_DISPLAY_NAMES, + IntegrationAccountTokenValidationError, + type IntegrationAccountTokenValidationInput, + type IntegrationAccountTokenValidationResult, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; + +const GitHubUserResponse = Schema.Struct({ + login: TrimmedNonEmptyString, + name: Schema.NullOr(TrimmedNonEmptyString), +}); + +const GitLabUserResponse = Schema.Struct({ + name: Schema.NullOr(TrimmedNonEmptyString), + username: TrimmedNonEmptyString, +}); + +const JiraCurrentUserResponse = Schema.Struct({ + displayName: TrimmedNonEmptyString, + emailAddress: Schema.NullOr(TrimmedNonEmptyString), +}); + +const LinearGraphQLError = Schema.Struct({ + message: TrimmedNonEmptyString, +}); + +const LinearViewerResponse = Schema.Struct({ + data: Schema.Struct({ + viewer: Schema.NullOr( + Schema.Struct({ + name: Schema.NullOr(TrimmedNonEmptyString), + email: Schema.NullOr(TrimmedNonEmptyString), + }), + ), + }), + errors: Schema.optional(Schema.Array(LinearGraphQLError)), +}); + +function validationError( + input: IntegrationAccountTokenValidationInput, + detail: string, + cause?: unknown, +) { + return new IntegrationAccountTokenValidationError({ + kind: input.kind, + detail, + ...(cause === undefined ? {} : { cause }), + }); +} + +function makeUrl(baseUrl: string, path: string): string { + return new URL(path, baseUrl).toString(); +} + +type IntegrationValidator = ( + input: IntegrationAccountTokenValidationInput, + httpClient: HttpClient.HttpClient, +) => Effect.Effect; + +const INTEGRATION_VALIDATORS: Record< + IntegrationAccountTokenValidationInput["kind"], + IntegrationValidator +> = { + github: (input, httpClient) => { + const request = HttpClientRequest.get("https://api.github.com/user").pipe( + HttpClientRequest.bearerToken(input.apiKey), + HttpClientRequest.acceptJson, + HttpClientRequest.setHeader("user-agent", "t3-code"), + HttpClientRequest.setHeader("x-github-api-version", "2022-11-28"), + ); + + return httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(GitHubUserResponse)), + Effect.mapError((cause) => + validationError( + input, + `Could not verify the ${INTEGRATION_DISPLAY_NAMES[input.kind]} token.`, + cause, + ), + ), + Effect.map((response) => ({ + accountLabel: response.name ?? response.login, + })), + ); + }, + gitlab: (input, httpClient) => { + const baseUrl = input.baseUrl ?? "https://gitlab.com"; + const request = HttpClientRequest.get(makeUrl(baseUrl, "/api/v4/user")).pipe( + HttpClientRequest.setHeader("private-token", input.apiKey), + HttpClientRequest.acceptJson, + ); + + return httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(GitLabUserResponse)), + Effect.mapError((cause) => + validationError( + input, + `Could not verify the ${INTEGRATION_DISPLAY_NAMES[input.kind]} token.`, + cause, + ), + ), + Effect.map((response) => ({ + accountLabel: response.name ?? response.username, + })), + ); + }, + jira: (input, httpClient) => { + if (input.baseUrl === undefined || input.baseUrl.length === 0) { + return Effect.fail( + validationError(input, "Jira requires a base URL before testing the token."), + ); + } + + if (input.accountName === undefined || input.accountName.length === 0) { + return Effect.fail( + validationError(input, "Jira requires an account name before testing the token."), + ); + } + + const request = HttpClientRequest.get(makeUrl(input.baseUrl, "/rest/api/3/myself")).pipe( + HttpClientRequest.basicAuth(input.accountName, input.apiKey), + HttpClientRequest.acceptJson, + ); + + return httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(JiraCurrentUserResponse)), + Effect.mapError((cause) => + validationError( + input, + `Could not verify the ${INTEGRATION_DISPLAY_NAMES[input.kind]} token.`, + cause, + ), + ), + Effect.map((response) => ({ + accountLabel: response.displayName, + })), + ); + }, + linear: (input, httpClient) => { + const request = HttpClientRequest.post("https://api.linear.app/graphql").pipe( + HttpClientRequest.bearerToken(input.apiKey), + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJsonUnsafe({ + query: "query Me { viewer { name email } }", + }), + ); + + return httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(LinearViewerResponse)), + Effect.mapError((cause) => + validationError( + input, + `Could not verify the ${INTEGRATION_DISPLAY_NAMES[input.kind]} token.`, + cause, + ), + ), + Effect.flatMap((response) => { + if ((response.errors?.length ?? 0) > 0) { + return Effect.fail( + validationError( + input, + response.errors?.[0]?.message ?? "Linear API returned an error.", + ), + ); + } + return Effect.succeed({ + accountLabel: + response.data.viewer?.name ?? response.data.viewer?.email ?? "Linear account", + }); + }), + ); + }, +}; + +export const testIntegrationToken = ( + input: IntegrationAccountTokenValidationInput, +): Effect.Effect< + IntegrationAccountTokenValidationResult, + IntegrationAccountTokenValidationError, + HttpClient.HttpClient +> => + Effect.gen(function* () { + if (input.apiKey === undefined) { + return yield* Effect.fail( + validationError(input, "An API key is required to test the token."), + ); + } + const httpClient = yield* HttpClient.HttpClient; + return yield* INTEGRATION_VALIDATORS[input.kind](input, httpClient); + }); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18de..62ddc9efc15 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + IntegrationAccountId, ProviderDriverKind, ProviderInstanceId, ServerSettings, @@ -589,4 +590,113 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("stores integration API keys outside settings.json and redacts them for clients", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const next = yield* serverSettings.updateSettings({ + integrations: { + github: [ + { + id: IntegrationAccountId.make("github_personal"), + name: "Personal", + apiKey: "ghp_secret", + apiKeyRedacted: true, + }, + ], + gitlab: [], + jira: [], + linear: [], + }, + }); + + assert.deepEqual(next.integrations.github[0], { + id: IntegrationAccountId.make("github_personal"), + name: "Personal", + apiKey: "ghp_secret", + apiKeyRedacted: true, + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "ghp_secret"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(raw).integrations.github, [ + { + id: IntegrationAccountId.make("github_personal"), + name: "Personal", + apiKey: "", + apiKeyRedacted: true, + }, + ]); + + assert.deepEqual( + ServerSettingsModule.redactServerSettingsForClient(next).integrations.github, + [ + { + id: IntegrationAccountId.make("github_personal"), + name: "Personal", + apiKey: "", + apiKeyRedacted: true, + }, + ], + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("migrates plaintext integration keys into the secret store on save", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const next = yield* serverSettings.updateSettings({ + integrations: { + github: [ + { + id: IntegrationAccountId.make("github_legacy_plaintext"), + name: "Legacy", + apiKey: "ghp_legacy_secret", + }, + ], + gitlab: [], + jira: [], + linear: [], + }, + }); + + assert.deepEqual(next.integrations.github[0], { + id: IntegrationAccountId.make("github_legacy_plaintext"), + name: "Legacy", + apiKey: "ghp_legacy_secret", + apiKeyRedacted: true, + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "ghp_legacy_secret"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(raw).integrations.github, [ + { + id: IntegrationAccountId.make("github_legacy_plaintext"), + name: "Legacy", + apiKey: "", + apiKeyRedacted: true, + }, + ]); + + assert.deepEqual( + ServerSettingsModule.redactServerSettingsForClient(next).integrations.github, + [ + { + id: IntegrationAccountId.make("github_legacy_plaintext"), + name: "Legacy", + apiKey: "", + apiKeyRedacted: true, + }, + ], + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640f..e75718f1a0d 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -15,6 +15,8 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, + type IntegrationAccount, + type IntegrationKind, isProviderDriverKind, type ModelSelection, type ProviderInstanceConfig, @@ -79,6 +81,13 @@ function providerEnvironmentSecretName(input: { return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; } +function integrationAccountSecretName(input: { + readonly kind: IntegrationKind; + readonly accountId: string; +}): string { + return `integration-${input.kind}-${input.accountId}`; +} + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -93,6 +102,18 @@ function redactProviderEnvironmentVariable( }; } +function redactIntegrationAccount(account: IntegrationAccount): IntegrationAccount { + if (!account.apiKeyRedacted && account.apiKey.length === 0) { + const { apiKeyRedacted: _omit, ...rest } = account; + return rest; + } + return { + ...account, + apiKey: "", + ...(account.apiKey.length > 0 || account.apiKeyRedacted ? { apiKeyRedacted: true } : {}), + }; +} + export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ @@ -105,7 +126,13 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS : instance, ]), ); - return { ...settings, providerInstances }; + const integrations = { + github: settings.integrations.github.map(redactIntegrationAccount), + gitlab: settings.integrations.gitlab.map(redactIntegrationAccount), + jira: settings.integrations.jira.map(redactIntegrationAccount), + linear: settings.integrations.linear.map(redactIntegrationAccount), + } satisfies ServerSettings["integrations"]; + return { ...settings, providerInstances, integrations }; } export class ServerSettingsService extends Context.Service< @@ -363,6 +390,52 @@ const make = Effect.gen(function* () { }; }); + const materializeIntegrationAccountSecrets = ( + settings: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const integrations: Record = { + github: [], + gitlab: [], + jira: [], + linear: [], + }; + + for (const [kind, accounts] of Object.entries(settings.integrations) as Array< + [IntegrationKind, IntegrationAccount[]] + >) { + const hydratedAccounts: IntegrationAccount[] = []; + for (const account of accounts) { + if (!account.apiKeyRedacted) { + hydratedAccounts.push(account); + continue; + } + const secret = yield* secretStore + .get(integrationAccountSecretName({ kind, accountId: account.id })) + .pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret", + cause, + }), + ), + ); + hydratedAccounts.push({ + ...account, + apiKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", + }); + } + integrations[kind] = hydratedAccounts; + } + + return { + ...settings, + integrations: integrations as ServerSettings["integrations"], + }; + }); + const persistProviderEnvironmentSecrets = ( current: ServerSettings, next: ServerSettings, @@ -464,6 +537,86 @@ const make = Effect.gen(function* () { }; }); + const persistIntegrationAccountSecrets = ( + current: ServerSettings, + next: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const integrations: Record = { + github: [], + gitlab: [], + jira: [], + linear: [], + }; + + const nextSecretKeys = new Set(); + for (const [kind, accounts] of Object.entries(next.integrations) as Array< + [IntegrationKind, IntegrationAccount[]] + >) { + const persistedAccounts: IntegrationAccount[] = []; + for (const account of accounts) { + const secretName = integrationAccountSecretName({ kind, accountId: account.id }); + if (account.apiKey.length > 0) { + yield* secretStore.set(secretName, textEncoder.encode(account.apiKey)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-secret", + cause, + }), + ), + ); + nextSecretKeys.add(secretName); + persistedAccounts.push(redactIntegrationAccount(account)); + continue; + } + + if (!account.apiKeyRedacted) { + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + cause, + }), + ), + ); + } else { + nextSecretKeys.add(secretName); + } + + persistedAccounts.push(redactIntegrationAccount(account)); + } + integrations[kind] = persistedAccounts; + } + + for (const [kind, accounts] of Object.entries(current.integrations) as Array< + [IntegrationKind, IntegrationAccount[]] + >) { + for (const account of accounts) { + const secretName = integrationAccountSecretName({ kind, accountId: account.id }); + if (nextSecretKeys.has(secretName)) continue; + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-stale-secret", + cause, + }), + ), + ); + } + } + + return { + ...next, + integrations: integrations as ServerSettings["integrations"], + }; + }); + const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( @@ -561,6 +714,7 @@ const make = Effect.gen(function* () { ready: Deferred.await(startedDeferred), getSettings: getSettingsFromCache.pipe( Effect.flatMap(materializeProviderEnvironmentSecrets), + Effect.flatMap(materializeIntegrationAccountSecrets), Effect.map(resolveTextGenerationProvider), ), updateSettings: (patch) => @@ -570,21 +724,24 @@ const make = Effect.gen(function* () { const nextPersisted = yield* persistProviderEnvironmentSecrets( current, applyServerSettingsPatch(current, patch), - ); + ).pipe(Effect.flatMap((settings) => persistIntegrationAccountSecrets(current, settings))); const next = yield* normalizeServerSettings(nextPersisted); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); yield* emitChange(next); const materialized = yield* materializeProviderEnvironmentSecrets(next); - return resolveTextGenerationProvider(materialized); + const materializedWithIntegrations = + yield* materializeIntegrationAccountSecrets(materialized); + return resolveTextGenerationProvider(materializedWithIntegrations); }), ), get streamChanges() { return Stream.fromPubSub(changesPubSub).pipe( Stream.mapEffect((settings) => materializeProviderEnvironmentSecrets(settings).pipe( + Effect.flatMap(materializeIntegrationAccountSecrets), Effect.catch((error: ServerSettingsError) => - Effect.logWarning("failed to materialize provider environment secrets", { + Effect.logWarning("failed to materialize server settings secrets", { operation: error.operation, providerInstanceId: error.providerInstanceId, environmentVariable: error.environmentVariable, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 554a942d78a..4d02586c23e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,6 +49,7 @@ import { AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, + IntegrationAccountTokenValidationError, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -77,6 +78,7 @@ import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as Integrations from "./integrations.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -289,6 +291,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope], + [WS_METHODS.serverTestIntegrationToken, AuthOrchestrationOperateScope], [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope], [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], @@ -1226,6 +1229,65 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => "rpc.aggregate": "server", }, ), + [WS_METHODS.serverTestIntegrationToken]: (input) => + observeRpcEffect( + WS_METHODS.serverTestIntegrationToken, + Effect.gen(function* () { + if ((input.useStoredToken ?? false) || input.apiKey === undefined) { + if (input.accountId === undefined) { + return yield* Effect.fail( + new IntegrationAccountTokenValidationError({ + kind: input.kind, + detail: "An account id is required to retest a stored token.", + }), + ); + } + + const settings = yield* serverSettings.getSettings; + const accounts = settings.integrations[input.kind]; + const account = accounts.find((candidate) => candidate.id === input.accountId); + if (account === undefined) { + return yield* Effect.fail( + new IntegrationAccountTokenValidationError({ + kind: input.kind, + detail: "Stored integration account not found.", + }), + ); + } + + if (account.apiKey.length === 0) { + return yield* Effect.fail( + new IntegrationAccountTokenValidationError({ + kind: input.kind, + detail: "Stored integration token is unavailable.", + }), + ); + } + + return yield* Integrations.testIntegrationToken({ + kind: input.kind, + accountName: input.accountName ?? account.name, + ...(input.baseUrl !== undefined + ? { baseUrl: input.baseUrl } + : account.baseUrl !== undefined + ? { baseUrl: account.baseUrl } + : {}), + apiKey: account.apiKey, + }); + } + + return yield* Integrations.testIntegrationToken({ + kind: input.kind, + ...(input.accountId !== undefined ? { accountId: input.accountId } : {}), + ...(input.accountName !== undefined ? { accountName: input.accountName } : {}), + ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}), + apiKey: input.apiKey, + }); + }), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..0444b844bb1 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -15,6 +15,15 @@ export const GitHubIcon: Icon = (props) => ( ); +export const LinearIcon: Icon = (props) => ( + + + +); + export const GitIcon: Icon = (props) => ( { }; export const GitLabIcon: Icon = (props) => ( - - - - - + + + +); + +export const JiraIcon: Icon = (props) => ( + + ); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx new file mode 100644 index 00000000000..1fb2474f77a --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -0,0 +1,592 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + INTEGRATION_DEFINITIONS, + INTEGRATION_DISPLAY_NAMES, + INTEGRATION_KINDS, + IntegrationAccountId, + type IntegrationAccount, + type IntegrationKind, +} from "@t3tools/contracts"; +import { CheckIcon, KeyRoundIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; + +import { usePrimaryEnvironment } from "~/state/environments"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { serverEnvironment } from "~/state/server"; +import { toastManager } from "../ui/toast"; +import { GitHubIcon, GitLabIcon, JiraIcon, LinearIcon } from "../Icons"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const INTEGRATION_ICON_BY_KIND: Record = { + github: GitHubIcon, + gitlab: GitLabIcon, + jira: JiraIcon, + linear: LinearIcon, +}; + +function slugifyAccountName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48); +} + +function nextAvailableAccountId( + kind: IntegrationKind, + name: string, + existingAccounts: readonly IntegrationAccount[], +): string { + const slug = slugifyAccountName(name); + if (slug.length === 0) return ""; + + const base = `${kind}_${slug}`; + if (!existingAccounts.some((account) => account.id === base)) { + return base; + } + + let suffix = 2; + while (existingAccounts.some((account) => account.id === `${base}_${suffix}`)) { + suffix += 1; + } + return `${base}_${suffix}`; +} + +function isDuplicateAccountName( + accounts: readonly IntegrationAccount[], + name: string, + excludeId?: string, +): boolean { + const normalized = name.trim().toLowerCase(); + return accounts.some( + (account) => account.id !== excludeId && account.name.trim().toLowerCase() === normalized, + ); +} + +interface AccountDialogState { + readonly kind: IntegrationKind; + readonly account: IntegrationAccount | null; +} + +function AccountDialog({ + state, + existingAccounts, + onSave, + onCancel, +}: { + state: AccountDialogState; + existingAccounts: readonly IntegrationAccount[]; + onSave: (account: IntegrationAccount) => void; + onCancel: () => void; +}) { + const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const testIntegrationToken = useAtomCommand(serverEnvironment.testIntegrationToken, { + reportFailure: false, + }); + const definition = INTEGRATION_DEFINITIONS[state.kind]; + const requiresBaseUrl = definition.baseUrlRequired === true; + const steps = requiresBaseUrl ? ["Name", "Site", "Token", "Review"] : ["Name", "Token", "Review"]; + + const [step, setStep] = useState(0); + const [name, setName] = useState(state.account?.name ?? ""); + const [baseUrl, setBaseUrl] = useState(state.account?.baseUrl ?? ""); + const [apiKey, setApiKey] = useState(""); + const [error, setError] = useState(null); + const [isTesting, setIsTesting] = useState(false); + + const isEdit = state.account !== null; + const preserveSavedToken = + isEdit && state.account?.apiKeyRedacted === true && apiKey.trim().length === 0; + const preserveSavedBaseUrl = + isEdit && (baseUrl.trim().length === 0 || baseUrl.trim() === (state.account?.baseUrl ?? "")); + + useEffect(() => { + setStep(0); + setName(state.account?.name ?? ""); + setBaseUrl(state.account?.baseUrl ?? ""); + setApiKey(""); + setError(null); + setIsTesting(false); + }, [state.account, state.kind]); + + const validateName = useCallback(() => { + const trimmedName = name.trim(); + if (trimmedName.length === 0) { + setError("Account name is required."); + return null; + } + if (isDuplicateAccountName(existingAccounts, trimmedName, state.account?.id)) { + setError(`An account named "${trimmedName}" already exists.`); + return null; + } + return trimmedName; + }, [existingAccounts, name, state.account?.id]); + + const validateBaseUrl = useCallback(() => { + if (!requiresBaseUrl) return null; + const trimmedBaseUrl = baseUrl.trim(); + if (trimmedBaseUrl.length === 0) { + if (state.account?.baseUrl !== undefined) { + return state.account.baseUrl; + } + setError(`${definition.baseUrlLabel ?? "Base URL"} is required.`); + return null; + } + try { + return new URL(trimmedBaseUrl).toString(); + } catch { + setError(`${definition.baseUrlLabel ?? "Base URL"} must be a valid URL.`); + return null; + } + }, [baseUrl, definition.baseUrlLabel, requiresBaseUrl, state.account?.baseUrl]); + + const handleNext = useCallback(() => { + setError(null); + if (step === 0) { + if (validateName() !== null) { + setStep(1); + } + return; + } + if (requiresBaseUrl && step === 1) { + if (validateBaseUrl() !== null) { + setStep(2); + } + return; + } + if ((requiresBaseUrl && step === 2) || (!requiresBaseUrl && step === 1)) { + if (apiKey.trim().length === 0 && !preserveSavedToken) { + setError(`${definition.tokenLabel} is required.`); + return; + } + setStep(requiresBaseUrl ? 3 : 2); + } + }, [ + apiKey, + definition.tokenLabel, + preserveSavedToken, + requiresBaseUrl, + step, + validateBaseUrl, + validateName, + ]); + + const handleSave = useCallback(async () => { + const trimmedName = validateName(); + if (trimmedName === null) { + setStep(0); + return; + } + + const normalizedBaseUrl = validateBaseUrl(); + if (requiresBaseUrl && normalizedBaseUrl === null) { + setStep(1); + return; + } + + const trimmedKey = apiKey.trim(); + if (!preserveSavedToken && trimmedKey.length === 0) { + setError(`${definition.tokenLabel} is required.`); + setStep(requiresBaseUrl ? 2 : 1); + return; + } + + if (environmentId === null) { + setError("Unable to validate integration tokens without an active environment."); + return; + } + + try { + setIsTesting(true); + const result = await testIntegrationToken({ + environmentId, + input: preserveSavedToken + ? { + kind: state.kind, + accountId: state.account?.id, + accountName: trimmedName, + ...(normalizedBaseUrl !== null ? { baseUrl: normalizedBaseUrl } : {}), + useStoredToken: true, + } + : { + kind: state.kind, + accountId: state.account?.id, + accountName: trimmedName, + ...(normalizedBaseUrl !== null ? { baseUrl: normalizedBaseUrl } : {}), + apiKey: trimmedKey, + }, + }); + + if (result._tag !== "Success") { + throw new Error("Could not verify the token."); + } + + toastManager.add({ + type: "success", + title: `${INTEGRATION_DISPLAY_NAMES[state.kind]} token verified`, + description: `Connected to ${result.value.accountLabel}.`, + }); + + const existing = state.account; + const nextId = + existing?.id ?? + IntegrationAccountId.make( + nextAvailableAccountId(state.kind, trimmedName, existingAccounts), + ); + onSave({ + id: nextId, + name: trimmedName, + ...(normalizedBaseUrl !== null + ? { baseUrl: normalizedBaseUrl } + : preserveSavedBaseUrl && existing?.baseUrl !== undefined + ? { baseUrl: existing.baseUrl } + : {}), + apiKey: trimmedKey, + ...(trimmedKey.length > 0 || preserveSavedToken ? { apiKeyRedacted: true } : {}), + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Could not verify the token."; + setError(message); + setStep(requiresBaseUrl ? 2 : 1); + toastManager.add({ + type: "error", + title: `${INTEGRATION_DISPLAY_NAMES[state.kind]} token check failed`, + description: message, + }); + } finally { + setIsTesting(false); + } + }, [ + apiKey, + definition.tokenLabel, + environmentId, + existingAccounts, + onSave, + preserveSavedBaseUrl, + preserveSavedToken, + requiresBaseUrl, + state.account, + state.kind, + validateBaseUrl, + validateName, + ]); + + const reviewBaseUrl = preserveSavedBaseUrl ? state.account?.baseUrl : baseUrl.trim(); + + const submitLabel = preserveSavedToken + ? "Save changes" + : isEdit + ? "Test token & save" + : "Test token & add account"; + + const currentStepLabel = steps[step] ?? steps[steps.length - 1] ?? "Review"; + + return ( + !open && onCancel()}> + + + {`${isEdit ? "Edit" : "Add"} ${INTEGRATION_DISPLAY_NAMES[state.kind]} account`} + {definition.accountHint} +
+ {steps.map((label, index) => { + const active = index === step; + const complete = index < step; + return ( + + ); + })} +
+ + +
+ {step === 0 ? ( + + ) : null} + + {requiresBaseUrl && step === 1 ? ( + + ) : null} + + {(requiresBaseUrl ? step === 2 : step === 1) ? ( + + ) : null} + + {(requiresBaseUrl ? step === 3 : step === 2) ? ( +
+

+ {preserveSavedToken + ? "We’ll keep the existing encrypted key and save these changes." + : "We’ll test this token from the server before saving the account."} +

+
+
+
Integration
+
+ {INTEGRATION_DISPLAY_NAMES[state.kind]} +
+
+
+
Account
+
+ {name.trim() || "Untitled account"} +
+
+ {requiresBaseUrl ? ( +
+
Base URL
+
{reviewBaseUrl || "Not set"}
+
+ ) : null} +
+
+ ) : null} + + {error ?

{error}

: null} +
+ + +
+ + {step > 0 ? ( + + ) : null} +
+ {currentStepLabel === "Review" ? ( + + ) : ( + + )} +
+ + + ); +} + +export function IntegrationsSettingsPanel() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const [activeDialog, setActiveDialog] = useState(null); + + const sections = useMemo( + () => + INTEGRATION_KINDS.map((kind) => ({ + kind, + accounts: settings.integrations[kind] ?? [], + })), + [settings.integrations], + ); + + const handleSaveAccount = useCallback( + (kind: IntegrationKind, account: IntegrationAccount) => { + const nextAccounts = (() => { + const currentAccounts: readonly IntegrationAccount[] = settings.integrations[kind] ?? []; + const existingIndex = currentAccounts.findIndex( + (candidate: IntegrationAccount) => candidate.id === account.id, + ); + if (existingIndex === -1) { + return [...currentAccounts, account]; + } + return currentAccounts.map((candidate: IntegrationAccount) => + candidate.id === account.id ? account : candidate, + ); + })(); + + void updateSettings({ + integrations: { + ...settings.integrations, + [kind]: nextAccounts, + }, + }); + setActiveDialog(null); + }, + [settings.integrations, updateSettings], + ); + + const handleDeleteAccount = useCallback( + (kind: IntegrationKind, accountId: string) => { + const currentAccounts: readonly IntegrationAccount[] = settings.integrations[kind] ?? []; + void updateSettings({ + integrations: { + ...settings.integrations, + [kind]: currentAccounts.filter((account: IntegrationAccount) => account.id !== accountId), + }, + }); + }, + [settings.integrations, updateSettings], + ); + + return ( + +
+

Integrations

+
+ + {sections.map(({ kind, accounts }) => { + const Icon = INTEGRATION_ICON_BY_KIND[kind]; + const definition = INTEGRATION_DEFINITIONS[kind]; + return ( + } + headerAction={ + + } + > + {accounts.length === 0 ? ( +
+ No {INTEGRATION_DISPLAY_NAMES[kind]} accounts yet. +
+ ) : ( +
+ {accounts.map((account) => ( + + + +
+ } + > +
+ + + {INTEGRATION_DISPLAY_NAMES[kind]} account + +
+ + ))} + + )} +
+ ); + })} + + {activeDialog ? ( + handleSaveAccount(activeDialog.kind, account)} + onCancel={() => setActiveDialog(null)} + /> + ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..c6f51d74d9d 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -6,6 +6,7 @@ import { GitBranchIcon, KeyboardIcon, Link2Icon, + PuzzleIcon, Settings2Icon, } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; @@ -26,6 +27,7 @@ export type SettingsSectionPath = | "/settings/general" | "/settings/keybindings" | "/settings/providers" + | "/settings/integrations" | "/settings/source-control" | "/settings/connections" | "/settings/archived"; @@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, + { label: "Integrations", to: "/settings/integrations", icon: PuzzleIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..4969a31fea0 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' +import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' @@ -57,6 +58,11 @@ const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ path: '/keybindings', getParentRoute: () => SettingsRoute, } as any) +const SettingsIntegrationsRoute = SettingsIntegrationsRouteImport.update({ + id: '/integrations', + path: '/integrations', + getParentRoute: () => SettingsRoute, +} as any) const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ id: '/general', path: '/general', @@ -97,6 +103,7 @@ export interface FileRoutesByFullPath { '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute @@ -110,6 +117,7 @@ export interface FileRoutesByTo { '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute @@ -126,6 +134,7 @@ export interface FileRoutesById { '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute @@ -143,6 +152,7 @@ export interface FileRouteTypes { | '/settings/connections' | '/settings/diagnostics' | '/settings/general' + | '/settings/integrations' | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' @@ -156,6 +166,7 @@ export interface FileRouteTypes { | '/settings/connections' | '/settings/diagnostics' | '/settings/general' + | '/settings/integrations' | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' @@ -171,6 +182,7 @@ export interface FileRouteTypes { | '/settings/connections' | '/settings/diagnostics' | '/settings/general' + | '/settings/integrations' | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' @@ -236,6 +248,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsKeybindingsRouteImport parentRoute: typeof SettingsRoute } + '/settings/integrations': { + id: '/settings/integrations' + path: '/integrations' + fullPath: '/settings/integrations' + preLoaderRoute: typeof SettingsIntegrationsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/general': { id: '/settings/general' path: '/general' @@ -300,6 +319,7 @@ interface SettingsRouteChildren { SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute + SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute @@ -310,6 +330,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, + SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, diff --git a/apps/web/src/routes/settings.integrations.tsx b/apps/web/src/routes/settings.integrations.tsx new file mode 100644 index 00000000000..641a3036c6f --- /dev/null +++ b/apps/web/src/routes/settings.integrations.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { IntegrationsSettingsPanel } from "../components/settings/IntegrationsSettings"; + +export const Route = createFileRoute("/settings/integrations")({ + component: IntegrationsSettingsPanel, +}); diff --git a/artifacts/pr/8c79c383/after/integrations-settings-after.png b/artifacts/pr/8c79c383/after/integrations-settings-after.png new file mode 100644 index 00000000000..1776e0012a2 Binary files /dev/null and b/artifacts/pr/8c79c383/after/integrations-settings-after.png differ diff --git a/artifacts/pr/8c79c383/after/integrations-wizard-after.png b/artifacts/pr/8c79c383/after/integrations-wizard-after.png new file mode 100644 index 00000000000..bb8e10c7414 Binary files /dev/null and b/artifacts/pr/8c79c383/after/integrations-wizard-after.png differ diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 92892431e45..a222528082e 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -61,6 +61,7 @@ export type EnvironmentStreamRpcTag = | EnvironmentStreamCommandRpcTag; export type EnvironmentUnaryRpcTag = Exclude; + const isRpcClientError = Schema.is(RpcClientError.RpcClientError); export type EnvironmentRpcInput = Parameters>[0]; diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index eb784183793..94b2c96b434 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -186,6 +186,12 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + testIntegrationToken: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:test-integration-token", + tag: WS_METHODS.serverTestIntegrationToken, + scheduler: configScheduler, + concurrency: configConcurrency, + }), signalProcess: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a2a8e9106aa..c0402a0baa5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -130,7 +130,14 @@ import { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, } from "./server.ts"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + IntegrationAccountTokenValidationError, + IntegrationAccountTokenValidationInput, + IntegrationAccountTokenValidationResult, + ServerSettings, + ServerSettingsError, + ServerSettingsPatch, +} from "./settings.ts"; import { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -207,6 +214,7 @@ export const WS_METHODS = { serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + serverTestIntegrationToken: "server.testIntegrationToken", serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", @@ -283,6 +291,12 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsServerTestIntegrationTokenRpc = Rpc.make(WS_METHODS.serverTestIntegrationToken, { + payload: IntegrationAccountTokenValidationInput, + success: IntegrationAccountTokenValidationResult, + error: Schema.Union([IntegrationAccountTokenValidationError, EnvironmentAuthorizationError]), +}); + export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { payload: Schema.Struct({}), success: SourceControlDiscoveryResult, @@ -687,6 +701,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsServerTestIntegrationTokenRpc, WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index aba97cbe205..7e173072a6e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { + DEFAULT_SERVER_SETTINGS, + INTEGRATION_DISPLAY_NAMES, + ServerSettings, + ServerSettingsPatch, +} from "./settings.ts"; const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); @@ -64,6 +69,19 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); }); +describe("ServerSettings.integrations", () => { + it("defaults to empty account arrays for GitHub and Linear", () => { + expect(DEFAULT_SERVER_SETTINGS.integrations).toEqual({ + github: [], + gitlab: [], + jira: [], + linear: [], + }); + expect(INTEGRATION_DISPLAY_NAMES.github).toBe("GitHub"); + expect(INTEGRATION_DISPLAY_NAMES.linear).toBe("Linear"); + }); +}); + describe("ServerSettings worktree defaults", () => { it("defaults start-from-origin off for legacy configs", () => { expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(false); @@ -106,6 +124,18 @@ describe("ServerSettingsPatch.providerInstances", () => { }); }); +describe("ServerSettingsPatch.integrations", () => { + it("treats integrations as an optional whole-map replacement", () => { + const patch = decodeServerSettingsPatch({}); + expect(patch.integrations).toBeUndefined(); + + const replacement = decodeServerSettingsPatch({ + integrations: { github: [], gitlab: [], jira: [], linear: [] }, + }); + expect(replacement.integrations).toEqual({ github: [], gitlab: [], jira: [], linear: [] }); + }); +}); + describe("ServerSettingsPatch string normalization", () => { it("trims string settings while decoding patches", () => { const patch = decodeServerSettingsPatch({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7ba267b1e72..a0ed3a3fbd8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -39,6 +39,122 @@ export const SidebarThreadPreviewCount = Schema.Int.check( export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +// ── Integrations ──────────────────────────────────────────────────── + +export const IntegrationKind = Schema.Literals(["github", "gitlab", "jira", "linear"]); +export type IntegrationKind = typeof IntegrationKind.Type; + +export const INTEGRATION_KINDS = [ + "github", + "gitlab", + "jira", + "linear", +] as const satisfies readonly IntegrationKind[]; + +export const INTEGRATION_DISPLAY_NAMES: Record = { + github: "GitHub", + gitlab: "GitLab", + jira: "Jira", + linear: "Linear", +}; + +export interface IntegrationDefinition { + readonly accountPlaceholder: string; + readonly accountHint: string; + readonly tokenLabel: string; + readonly baseUrlLabel?: string | undefined; + readonly baseUrlPlaceholder?: string | undefined; + readonly baseUrlRequired?: boolean | undefined; +} + +export const INTEGRATION_DEFINITIONS: Record = { + github: { + accountPlaceholder: "Personal", + accountHint: "Connect a GitHub personal access token.", + tokenLabel: "personal access token", + }, + gitlab: { + accountPlaceholder: "Work", + accountHint: "Connect a GitLab personal access token.", + tokenLabel: "personal access token", + }, + jira: { + accountPlaceholder: "Issue tracking", + accountHint: "Use your Jira email or username with the API token.", + tokenLabel: "API token", + baseUrlLabel: "Jira URL", + baseUrlPlaceholder: "https://your-domain.atlassian.net", + baseUrlRequired: true, + }, + linear: { + accountPlaceholder: "Design", + accountHint: "Connect a Linear API token.", + tokenLabel: "API token", + }, +}; + +const INTEGRATION_ACCOUNT_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; +const integrationAccountIdSchema = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(INTEGRATION_ACCOUNT_ID_PATTERN), +); + +export const IntegrationAccountId = integrationAccountIdSchema.pipe( + Schema.brand("IntegrationAccountId"), +); +export type IntegrationAccountId = typeof IntegrationAccountId.Type; + +export const IntegrationAccount = Schema.Struct({ + id: IntegrationAccountId, + name: TrimmedNonEmptyString, + baseUrl: Schema.optionalKey(TrimmedString), + apiKey: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + apiKeyRedacted: Schema.optionalKey(Schema.Boolean), +}); +export type IntegrationAccount = typeof IntegrationAccount.Type; + +export const IntegrationsSettings = Schema.Struct({ + github: Schema.Array(IntegrationAccount).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + gitlab: Schema.Array(IntegrationAccount).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + jira: Schema.Array(IntegrationAccount).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + linear: Schema.Array(IntegrationAccount).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +export type IntegrationsSettings = typeof IntegrationsSettings.Type; + +export const DEFAULT_INTEGRATIONS_SETTINGS: IntegrationsSettings = Schema.decodeSync( + IntegrationsSettings, +)({}); + +export const IntegrationAccountTokenValidationInput = Schema.Struct({ + kind: IntegrationKind, + accountId: Schema.optionalKey(IntegrationAccountId), + accountName: Schema.optionalKey(TrimmedString), + baseUrl: Schema.optionalKey(TrimmedString), + apiKey: Schema.optionalKey(TrimmedNonEmptyString), + useStoredToken: Schema.optionalKey(Schema.Boolean), +}); +export type IntegrationAccountTokenValidationInput = + typeof IntegrationAccountTokenValidationInput.Type; + +export const IntegrationAccountTokenValidationResult = Schema.Struct({ + accountLabel: TrimmedNonEmptyString, +}); +export type IntegrationAccountTokenValidationResult = + typeof IntegrationAccountTokenValidationResult.Type; + +export class IntegrationAccountTokenValidationError extends Schema.TaggedErrorClass()( + "IntegrationAccountTokenValidationError", + { + kind: IntegrationKind, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Integration token validation failed for ${INTEGRATION_DISPLAY_NAMES[this.kind]}: ${this.detail}`; + } +} + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -408,6 +524,9 @@ export const ServerSettings = Schema.Struct({ providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + integrations: IntegrationsSettings.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_INTEGRATIONS_SETTINGS)), + ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -530,6 +649,14 @@ export const ServerSettingsPatch = Schema.Struct({ // patches risk leaving driver-specific config in a half-merged state. // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), + integrations: Schema.optionalKey( + Schema.Struct({ + github: Schema.optionalKey(Schema.Array(IntegrationAccount)), + gitlab: Schema.optionalKey(Schema.Array(IntegrationAccount)), + jira: Schema.optionalKey(Schema.Array(IntegrationAccount)), + linear: Schema.optionalKey(Schema.Array(IntegrationAccount)), + }), + ), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 5bec7d386b6..2a07cf27ffb 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + IntegrationAccountId, ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; @@ -194,4 +195,48 @@ describe("serverSettings helpers", () => { config: { homePath: "~/.codex" }, }); }); + + it("replaces integration account arrays instead of merging them by index", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + integrations: { + github: [ + { + id: IntegrationAccountId.make("github_personal"), + name: "Personal", + apiKey: "secret", + apiKeyRedacted: true, + }, + ], + gitlab: [], + jira: [], + linear: [], + }, + }; + + expect( + applyServerSettingsPatch(current, { + integrations: { + github: [ + { + id: IntegrationAccountId.make("github_work"), + name: "Work", + apiKey: "", + apiKeyRedacted: true, + }, + ], + gitlab: [], + jira: [], + linear: [], + }, + }).integrations.github, + ).toEqual([ + { + id: IntegrationAccountId.make("github_work"), + name: "Work", + apiKey: "", + apiKeyRedacted: true, + }, + ]); + }); });