diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts index 99af278a35..cb2eee5193 100644 --- a/packages/cli/src/commands/handlers/console/login.ts +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -46,7 +46,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo yield* request(() => timeline.pending("Starting authorization...")) const started = yield* request((signal) => - client.integration.connect.oauth( + client.integration.oauth.connect( { integrationID, methodID: method.id, @@ -59,8 +59,8 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo const attempt = started.data yield* Effect.addFinalizer(() => request(() => - client.integration.attempt.cancel( - { attemptID: attempt.attemptID, location }, + client.integration.oauth.cancel( + { integrationID, attemptID: attempt.attemptID, location }, { signal: AbortSignal.timeout(5_000) }, ), ).pipe(Effect.ignore), @@ -75,7 +75,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo }).pipe(Effect.ignore) yield* request(() => timeline.pending("Waiting for authorization...")) - const status = yield* waitForConsoleLogin(client, attempt.attemptID) + const status = yield* waitForConsoleLogin(client, integrationID, attempt.attemptID) if (status.status === "failed") yield* Effect.fail(new Error(status.message)) if (status.status === "expired") yield* Effect.fail(new Error("Device code expired")) @@ -85,11 +85,12 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* ( client: OpenCodeClient, + integrationID: string, attemptID: string, ) { while (true) { const response = yield* request((signal) => - client.integration.attempt.status({ attemptID, location }, { signal }), + client.integration.oauth.status({ integrationID, attemptID, location }, { signal }), ) if (response.data.status !== "pending") return response.data yield* Effect.sleep(500) diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 32e728b435..2ca5259953 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -32,7 +32,7 @@ export default Runtime.handler( return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) const started = yield* Effect.promise(() => - client.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }), + client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, inputs: {}, location }), ) const attempt = started.data if (attempt.mode === "code") @@ -40,7 +40,7 @@ export default Runtime.handler( process.stdout.write(attempt.instructions + EOL + attempt.url + EOL) - const result = yield* poll(client, attempt.attemptID) + const result = yield* poll(client, integration.id, attempt.attemptID) if (result.status === "complete") { process.stdout.write(`Authenticated with ${input.name}` + EOL) return @@ -52,15 +52,16 @@ export default Runtime.handler( const poll = ( client: OpenCodeClient, + integrationID: string, attemptID: string, ): Effect.Effect> => Effect.gen(function* () { - const status = yield* Effect.promise(() => client.integration.attempt.status({ attemptID, location })).pipe( - Effect.map((result) => result.data), - ) + const status = yield* Effect.promise(() => + client.integration.oauth.status({ integrationID, attemptID, location }), + ).pipe(Effect.map((result) => result.data)) if (status.status === "pending") { yield* Effect.sleep("1 second") - return yield* poll(client, attemptID) + return yield* poll(client, integrationID, attemptID) } return status }) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 1da8209607..8028af9925 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -419,7 +419,7 @@ export type IntegrationConnectKeyOperation = ( input: Endpoint10_2Input, ) => Effect.Effect -type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Request = Parameters[0] export type Endpoint10_3Input = { readonly integrationID: Endpoint10_3Request["params"]["integrationID"] readonly location?: Endpoint10_3Request["query"]["location"] @@ -427,55 +427,54 @@ export type Endpoint10_3Input = { readonly inputs: Endpoint10_3Request["payload"]["inputs"] readonly label?: Endpoint10_3Request["payload"]["label"] } -export type Endpoint10_3Output = EffectValue> -export type IntegrationConnectOauthOperation = ( +export type Endpoint10_3Output = EffectValue> +export type IntegrationOauthConnectOperation = ( input: Endpoint10_3Input, ) => Effect.Effect -type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Request = Parameters[0] export type Endpoint10_4Input = { + readonly integrationID: Endpoint10_4Request["params"]["integrationID"] readonly attemptID: Endpoint10_4Request["params"]["attemptID"] readonly location?: Endpoint10_4Request["query"]["location"] } -export type Endpoint10_4Output = EffectValue> -export type IntegrationAttemptStatusOperation = ( +export type Endpoint10_4Output = EffectValue> +export type IntegrationOauthStatusOperation = ( input: Endpoint10_4Input, ) => Effect.Effect -type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Request = Parameters[0] export type Endpoint10_5Input = { + readonly integrationID: Endpoint10_5Request["params"]["integrationID"] readonly attemptID: Endpoint10_5Request["params"]["attemptID"] readonly location?: Endpoint10_5Request["query"]["location"] readonly code?: Endpoint10_5Request["payload"]["code"] } -export type Endpoint10_5Output = EffectValue< - ReturnType -> -export type IntegrationAttemptCompleteOperation = ( +export type Endpoint10_5Output = EffectValue> +export type IntegrationOauthCompleteOperation = ( input: Endpoint10_5Input, ) => Effect.Effect -type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Request = Parameters[0] export type Endpoint10_6Input = { + readonly integrationID: Endpoint10_6Request["params"]["integrationID"] readonly attemptID: Endpoint10_6Request["params"]["attemptID"] readonly location?: Endpoint10_6Request["query"]["location"] } -export type Endpoint10_6Output = EffectValue> -export type IntegrationAttemptCancelOperation = ( +export type Endpoint10_6Output = EffectValue> +export type IntegrationOauthCancelOperation = ( input: Endpoint10_6Input, ) => Effect.Effect export interface IntegrationApi { readonly list: IntegrationListOperation readonly get: IntegrationGetOperation - readonly connect: { - readonly key: IntegrationConnectKeyOperation - readonly oauth: IntegrationConnectOauthOperation - } - readonly attempt: { - readonly status: IntegrationAttemptStatusOperation - readonly complete: IntegrationAttemptCompleteOperation - readonly cancel: IntegrationAttemptCancelOperation + readonly connect: { readonly key: IntegrationConnectKeyOperation } + readonly oauth: { + readonly connect: IntegrationOauthConnectOperation + readonly status: IntegrationOauthStatusOperation + readonly complete: IntegrationOauthCompleteOperation + readonly cancel: IntegrationOauthCancelOperation } } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index ed2918eb9d..c85469d3b3 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -518,7 +518,7 @@ const Endpoint10_2 = (raw: RawClient["server.integration"]) => (input: Endpoint1 payload: { key: input["key"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_3Request = Parameters[0] +type Endpoint10_3Request = Parameters[0] type Endpoint10_3Input = { readonly integrationID: Endpoint10_3Request["params"]["integrationID"] readonly location?: Endpoint10_3Request["query"]["location"] @@ -527,52 +527,60 @@ type Endpoint10_3Input = { readonly label?: Endpoint10_3Request["payload"]["label"] } const Endpoint10_3 = (raw: RawClient["server.integration"]) => (input: Endpoint10_3Input) => - raw["integration.connect.oauth"]({ + raw["integration.oauth.connect"]({ params: { integrationID: input["integrationID"] }, query: { location: input["location"] }, payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_4Request = Parameters[0] +type Endpoint10_4Request = Parameters[0] type Endpoint10_4Input = { + readonly integrationID: Endpoint10_4Request["params"]["integrationID"] readonly attemptID: Endpoint10_4Request["params"]["attemptID"] readonly location?: Endpoint10_4Request["query"]["location"] } const Endpoint10_4 = (raw: RawClient["server.integration"]) => (input: Endpoint10_4Input) => - raw["integration.attempt.status"]({ - params: { attemptID: input["attemptID"] }, + raw["integration.oauth.status"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_5Request = Parameters[0] +type Endpoint10_5Request = Parameters[0] type Endpoint10_5Input = { + readonly integrationID: Endpoint10_5Request["params"]["integrationID"] readonly attemptID: Endpoint10_5Request["params"]["attemptID"] readonly location?: Endpoint10_5Request["query"]["location"] readonly code?: Endpoint10_5Request["payload"]["code"] } const Endpoint10_5 = (raw: RawClient["server.integration"]) => (input: Endpoint10_5Input) => - raw["integration.attempt.complete"]({ - params: { attemptID: input["attemptID"] }, + raw["integration.oauth.complete"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, payload: { code: input["code"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_6Request = Parameters[0] +type Endpoint10_6Request = Parameters[0] type Endpoint10_6Input = { + readonly integrationID: Endpoint10_6Request["params"]["integrationID"] readonly attemptID: Endpoint10_6Request["params"]["attemptID"] readonly location?: Endpoint10_6Request["query"]["location"] } const Endpoint10_6 = (raw: RawClient["server.integration"]) => (input: Endpoint10_6Input) => - raw["integration.attempt.cancel"]({ - params: { attemptID: input["attemptID"] }, + raw["integration.oauth.cancel"]({ + params: { integrationID: input["integrationID"], attemptID: input["attemptID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) const adaptGroup10 = (raw: RawClient["server.integration"]) => ({ list: Endpoint10_0(raw), get: Endpoint10_1(raw), - connect: { key: Endpoint10_2(raw), oauth: Endpoint10_3(raw) }, - attempt: { status: Endpoint10_4(raw), complete: Endpoint10_5(raw), cancel: Endpoint10_6(raw) }, + connect: { key: Endpoint10_2(raw) }, + oauth: { + connect: Endpoint10_3(raw), + status: Endpoint10_4(raw), + complete: Endpoint10_5(raw), + cancel: Endpoint10_6(raw), + }, }) type Endpoint11_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index b6f4d44b67..864b158004 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -84,14 +84,14 @@ import type { IntegrationGetOutput, IntegrationConnectKeyInput, IntegrationConnectKeyOutput, - IntegrationConnectOauthInput, - IntegrationConnectOauthOutput, - IntegrationAttemptStatusInput, - IntegrationAttemptStatusOutput, - IntegrationAttemptCompleteInput, - IntegrationAttemptCompleteOutput, - IntegrationAttemptCancelInput, - IntegrationAttemptCancelOutput, + IntegrationOauthConnectInput, + IntegrationOauthConnectOutput, + IntegrationOauthStatusInput, + IntegrationOauthStatusOutput, + IntegrationOauthCompleteInput, + IntegrationOauthCompleteOutput, + IntegrationOauthCancelInput, + IntegrationOauthCancelOutput, McpListInput, McpListOutput, McpResourceCatalogInput, @@ -901,8 +901,10 @@ export function make(options: ClientOptions) { }, requestOptions, ), - oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => - request( + }, + oauth: { + connect: (input: IntegrationOauthConnectInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, @@ -914,13 +916,11 @@ export function make(options: ClientOptions) { }, requestOptions, ), - }, - attempt: { - status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => - request( + status: (input: IntegrationOauthStatusInput, requestOptions?: RequestOptions) => + request( { method: "GET", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`, query: { location: input["location"] }, successStatus: 200, declaredStatuses: [401, 400], @@ -928,11 +928,11 @@ export function make(options: ClientOptions) { }, requestOptions, ), - complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => - request( + complete: (input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions) => + request( { method: "POST", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}/complete`, query: { location: input["location"] }, body: { code: input["code"] }, successStatus: 204, @@ -941,11 +941,11 @@ export function make(options: ClientOptions) { }, requestOptions, ), - cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => - request( + cancel: (input: IntegrationOauthCancelInput, requestOptions?: RequestOptions) => + request( { method: "DELETE", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`, query: { location: input["location"] }, successStatus: 204, declaredStatuses: [401, 400], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e2ed9a9f9c..882dadc086 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -3284,7 +3284,7 @@ export type IntegrationConnectKeyInput = { export type IntegrationConnectKeyOutput = void -export type IntegrationConnectOauthInput = { +export type IntegrationOauthConnectInput = { readonly integrationID: { readonly integrationID: string }["integrationID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -3306,7 +3306,7 @@ export type IntegrationConnectOauthInput = { }["label"] } -export type IntegrationConnectOauthOutput = { +export type IntegrationOauthConnectOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } data: { attemptID: string @@ -3317,36 +3317,39 @@ export type IntegrationConnectOauthOutput = { } } -export type IntegrationAttemptStatusInput = { - readonly attemptID: { readonly attemptID: string }["attemptID"] +export type IntegrationOauthStatusInput = { + readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] + readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type IntegrationAttemptStatusOutput = { +export type IntegrationOauthStatusOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } data: IntegrationAttemptStatus } -export type IntegrationAttemptCompleteInput = { - readonly attemptID: { readonly attemptID: string }["attemptID"] +export type IntegrationOauthCompleteInput = { + readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] + readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] readonly code?: { readonly code?: string | undefined }["code"] } -export type IntegrationAttemptCompleteOutput = void +export type IntegrationOauthCompleteOutput = void -export type IntegrationAttemptCancelInput = { - readonly attemptID: { readonly attemptID: string }["attemptID"] +export type IntegrationOauthCancelInput = { + readonly integrationID: { readonly integrationID: string; readonly attemptID: string }["integrationID"] + readonly attemptID: { readonly integrationID: string; readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type IntegrationAttemptCancelOutput = void +export type IntegrationOauthCancelOutput = void export type McpListInput = { readonly location?: { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 502bcae678..a0166f6f79 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -36,9 +36,9 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.debug)).toEqual(["location"]) expect(Object.keys(client.debug.location)).toEqual(["list", "evict"]) expect(Object.keys(client.message)).toEqual(["list"]) - expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"]) - expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"]) - expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"]) + expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "oauth"]) + expect(Object.keys(client.integration.connect)).toEqual(["key"]) + expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 236d027f3f..f987d87eda 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -159,17 +159,6 @@ export interface Interface extends State.Transformable { /** User-facing label for the stored credential. */ readonly label?: string }) => Effect.Effect - /** Starts a stateful OAuth attempt. */ - readonly oauth: (input: { - /** Integration being authenticated. */ - readonly integrationID: ID - /** OAuth method selected by the caller. */ - readonly methodID: MethodID - /** Answers to the method's optional prompts. */ - readonly inputs: Inputs - /** User-facing label for the credential created on completion. */ - readonly label?: string - }) => Effect.Effect /** Updates a stored credential exposed as a connection. */ readonly update: ( credentialID: Credential.ID, @@ -178,18 +167,27 @@ export interface Interface extends State.Transformable { /** Removes a stored credential connection. */ readonly remove: (credentialID: Credential.ID) => Effect.Effect } - readonly attempt: { + readonly oauth: { + /** Starts a stateful OAuth attempt. */ + readonly connect: (input: { + readonly integrationID: ID + readonly methodID: MethodID + readonly inputs: Inputs + readonly label?: string + }) => Effect.Effect /** Returns the current state of an OAuth attempt. */ - readonly status: (attemptID: AttemptID) => Effect.Effect + readonly status: (input: { + readonly integrationID: ID + readonly attemptID: AttemptID + }) => Effect.Effect /** Completes the attempt and stores its credential. */ readonly complete: (input: { - /** Opaque handle returned by `oauth`. */ + readonly integrationID: ID readonly attemptID: AttemptID - /** Authorization code required by attempts in code mode. */ readonly code?: string }) => Effect.Effect /** Cancels an attempt and releases its resources. */ - readonly cancel: (attemptID: AttemptID) => Effect.Effect + readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect } } @@ -213,6 +211,7 @@ type PendingAttempt = { } type TerminalAttempt = { status: "complete" | "failed" | "expired" + integrationID: ID message?: string removeAt: number time: AttemptTime @@ -332,6 +331,7 @@ const layer = Layer.effect( ? { ...match, persisting: true } : { status: "failed" as const, + integrationID: match.integrationID, message: message(exit.cause), time: match.time, removeAt: now + terminalRetention, @@ -362,13 +362,19 @@ const layer = Layer.effect( ) const settledAt = yield* Clock.currentTimeMillis const terminal: TerminalAttempt = Exit.isSuccess(persistence) - ? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention } + ? { + status: "complete", + integrationID: attempt.integrationID, + time: attempt.time, + removeAt: settledAt + terminalRetention, + } : { status: "failed", - message: message(persistence.cause), - time: attempt.time, - removeAt: settledAt + terminalRetention, - } + integrationID: attempt.integrationID, + message: message(persistence.cause), + time: attempt.time, + removeAt: settledAt + terminalRetention, + } // Persisting attempts cannot be cancelled, expired, or claimed again. yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal)) if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause) @@ -387,7 +393,12 @@ const layer = Layer.effect( for (const [id, attempt] of current) { if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) { scopes.push(attempt.scope) - next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention }) + next.set(id, { + status: "expired", + integrationID: attempt.integrationID, + time: attempt.time, + removeAt: now + terminalRetention, + }) continue } if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id) @@ -399,6 +410,53 @@ const layer = Layer.effect( yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope)) + const connectOAuth = Effect.fn("Integration.oauth.connect")(function* (input: { + readonly integrationID: ID + readonly methodID: MethodID + readonly inputs: Inputs + readonly label?: string + }) { + const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) + if (!method) { + return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`)) + } + const attemptScope = yield* Scope.fork(scope) + const authorization = yield* authorize(method.authorize(input.inputs)).pipe( + Scope.provide(attemptScope), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)), + ) + const id = AttemptID.create() + const created = yield* Clock.currentTimeMillis + const time = { created, expires: created + attemptLifetime } + yield* SynchronizedRef.update(attempts, (current) => + new Map(current).set(id, { + status: "pending", + completing: authorization.mode === "auto", + persisting: false, + authorization, + integrationID: input.integrationID, + methodID: input.methodID, + label: input.label, + scope: attemptScope, + time, + }), + ) + if (authorization.mode === "auto") { + yield* authorization.callback.pipe( + Effect.exit, + Effect.flatMap((exit) => settle(id, exit)), + Effect.forkIn(attemptScope, { startImmediately: true }), + ) + } + return new Attempt({ + attemptID: id, + url: authorization.url, + instructions: authorization.instructions, + mode: authorization.mode, + time, + }) + }) + return Service.of({ transform: state.transform, reload: state.reload, @@ -451,47 +509,6 @@ const layer = Layer.effect( yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID }) yield* events.publish(Event.Updated, {}) }), - oauth: Effect.fn("Integration.connection.oauth")(function* (input) { - const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) - if (!method) { - return yield* Effect.die(new Error(`OAuth method not found: ${input.integrationID}/${input.methodID}`)) - } - const attemptScope = yield* Scope.fork(scope) - const authorization = yield* authorize(method.authorize(input.inputs)).pipe( - Scope.provide(attemptScope), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)), - ) - const id = AttemptID.create() - const created = yield* Clock.currentTimeMillis - const time = { created, expires: created + attemptLifetime } - yield* SynchronizedRef.update(attempts, (current) => - new Map(current).set(id, { - status: "pending", - completing: authorization.mode === "auto", - persisting: false, - authorization, - integrationID: input.integrationID, - methodID: input.methodID, - label: input.label, - scope: attemptScope, - time, - }), - ) - if (authorization.mode === "auto") { - yield* authorization.callback.pipe( - Effect.exit, - Effect.flatMap((exit) => settle(id, exit)), - Effect.forkIn(attemptScope, { startImmediately: true }), - ) - } - return new Attempt({ - attemptID: id, - url: authorization.url, - instructions: authorization.instructions, - mode: authorization.mode, - time, - }) - }), update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) { const credential = yield* credentials.get(credentialID) yield* credentials.update(credentialID, updates) @@ -509,19 +526,22 @@ const layer = Layer.effect( yield* events.publish(Event.Updated, {}) }), }, - attempt: { - status: Effect.fn("Integration.attempt.status")(function* (attemptID) { - const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID) - if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${attemptID}`)) + oauth: { + connect: connectOAuth, + status: Effect.fn("Integration.oauth.status")(function* (input) { + const attempt = (yield* SynchronizedRef.get(attempts)).get(input.attemptID) + if (!attempt || attempt.integrationID !== input.integrationID) + return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`)) if (attempt.status === "failed") { return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time } } return { status: attempt.status, time: attempt.time } }), - complete: Effect.fn("Integration.attempt.complete")(function* (input) { + complete: Effect.fn("Integration.oauth.complete")(function* (input) { const attempt = yield* SynchronizedRef.modify(attempts, (current) => { const match = current.get(input.attemptID) - if (!match || match.status !== "pending" || match.completing) return [match, current] + if (!match || match.integrationID !== input.integrationID) return [undefined, current] + if (match.status !== "pending" || match.completing) return [match, current] if (match.authorization.mode === "code" && input.code === undefined) return [match, current] return [match, new Map(current).set(input.attemptID, { ...match, completing: true })] }) @@ -540,12 +560,13 @@ const layer = Layer.effect( yield* settle(input.attemptID, exit) if (Exit.isFailure(exit)) return yield* exit }), - cancel: Effect.fn("Integration.attempt.cancel")(function* (attemptID) { + cancel: Effect.fn("Integration.oauth.cancel")(function* (input) { const attempt = yield* SynchronizedRef.modify(attempts, (current) => { - const match = current.get(attemptID) - if (!match || match.status !== "pending" || match.persisting) return [undefined, current] + const match = current.get(input.attemptID) + if (!match || match.integrationID !== input.integrationID || match.status !== "pending" || match.persisting) + return [undefined, current] const next = new Map(current) - next.delete(attemptID) + next.delete(input.attemptID) return [match, next] }) if (attempt) yield* Scope.close(attempt.scope, Exit.void) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 5831991df1..f6d0470a0f 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -173,21 +173,35 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int key: input.key, label: input.label, }), - oauth: (input) => + }, + oauth: { + connect: (input) => response( - integration.connection.oauth({ + integration.oauth.connect({ integrationID: Integration.ID.make(input.integrationID), methodID: Integration.MethodID.make(input.methodID), inputs: input.inputs, label: input.label, }), ), - }, - attempt: { - status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), + status: (input) => + response( + integration.oauth.status({ + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + }), + ), complete: (input) => - integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), - cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), + integration.oauth.complete({ + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + code: input.code, + }), + cancel: (input) => + integration.oauth.cancel({ + integrationID: Integration.ID.make(input.integrationID), + attemptID: Integration.AttemptID.make(input.attemptID), + }), }, reload: integration.reload, connection: { diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 72adc9ec35..04fb8aee96 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -70,7 +70,8 @@ export function fromPromise(plugin: Plugin) { catalog: { provider: { list: (input) => run(host.catalog.provider.list(input)), - get: (input) => run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })), + get: (input) => + run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })), }, model: { list: (input) => run(host.catalog.model.list(input)), @@ -96,35 +97,40 @@ export function fromPromise(plugin: Plugin) { ), connect: { key: (input) => - run(host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) })), - oauth: (input) => run( - host.integration.connect.oauth({ + host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }), + ), + }, + oauth: { + connect: (input) => + run( + host.integration.oauth.connect({ ...input, integrationID: Integration.ID.make(input.integrationID), methodID: Integration.MethodID.make(input.methodID), }), ), - }, - attempt: { status: (input) => run( - host.integration.attempt.status({ + host.integration.oauth.status({ ...input, + integrationID: Integration.ID.make(input.integrationID), attemptID: Integration.AttemptID.make(input.attemptID), }), ), complete: (input) => run( - host.integration.attempt.complete({ + host.integration.oauth.complete({ ...input, + integrationID: Integration.ID.make(input.integrationID), attemptID: Integration.AttemptID.make(input.attemptID), }), ), cancel: (input) => run( - host.integration.attempt.cancel({ + host.integration.oauth.cancel({ ...input, + integrationID: Integration.ID.make(input.integrationID), attemptID: Integration.AttemptID.make(input.attemptID), }), ), diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index 0cbf46bb44..6fd4fa5c01 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -181,14 +181,14 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {}, label: "Personal", }) expect(attempt.mode).toBe("code") - yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }) + yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID, code: "1234" }) expect((yield* credentials.list(integrationID))[0]).toEqual( expect.objectContaining({ @@ -230,12 +230,17 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) - expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf( - Integration.CodeRequiredError, - ) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + expect( + yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID }).pipe(Effect.flip), + ).toBeInstanceOf(Integration.CodeRequiredError) expect(closed).toBe(false) - yield* integrations.attempt.cancel(attempt.attemptID) + yield* integrations.oauth.cancel({ + integrationID: Integration.ID.make("other"), + attemptID: attempt.attemptID, + }) + expect(closed).toBe(false) + yield* integrations.oauth.cancel({ integrationID, attemptID: attempt.attemptID }) expect(closed).toBe(true) expect(yield* credentials.list(integrationID)).toEqual([]) }), @@ -263,9 +268,9 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) yield* Effect.yieldNow - expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({ status: "complete", time: attempt.time, }) @@ -301,12 +306,12 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) - const exit = yield* integrations.attempt - .complete({ attemptID: attempt.attemptID, code: "1234" }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) + const exit = yield* integrations.oauth + .complete({ integrationID, attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) - expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({ status: "failed", message: "credential persistence failed", time: attempt.time, @@ -337,11 +342,11 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} }) expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10))) yield* TestClock.adjust(Duration.minutes(10)) yield* Effect.yieldNow - expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({ status: "expired", time: attempt.time, }) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index f6b39e86aa..e057cf9c3d 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -187,11 +187,11 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe active: unusedIntegration, resolve: unusedIntegration, key: unusedIntegration, - oauth: unusedIntegration, update: unusedIntegration, remove: unusedIntegration, }, - attempt: { + oauth: { + connect: unusedIntegration, status: unusedIntegration, complete: unusedIntegration, cancel: unusedIntegration, diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 8dafec5025..e5e7cf271c 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -48,12 +48,12 @@ export function host(overrides: Overrides = {}): PluginContext { get: () => Effect.die("unused integration.get"), connect: { key: () => Effect.die("unused integration.connect.key"), - oauth: () => Effect.die("unused integration.connect.oauth"), }, - attempt: { - status: () => Effect.die("unused integration.attempt.status"), - complete: () => Effect.die("unused integration.attempt.complete"), - cancel: () => Effect.die("unused integration.attempt.cancel"), + oauth: { + connect: () => Effect.die("unused integration.oauth.connect"), + status: () => Effect.die("unused integration.oauth.status"), + complete: () => Effect.die("unused integration.oauth.complete"), + cancel: () => Effect.die("unused integration.oauth.cancel"), }, transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), @@ -193,12 +193,12 @@ export function integrationHost(integration: Integration.Interface): PluginConte get: () => Effect.die("unused integration.get"), connect: { key: () => Effect.die("unused integration.connect.key"), - oauth: () => Effect.die("unused integration.connect.oauth"), }, - attempt: { - status: () => Effect.die("unused integration.attempt.status"), - complete: () => Effect.die("unused integration.attempt.complete"), - cancel: () => Effect.die("unused integration.attempt.cancel"), + oauth: { + connect: () => Effect.die("unused integration.oauth.connect"), + status: () => Effect.die("unused integration.oauth.status"), + complete: () => Effect.die("unused integration.oauth.complete"), + cancel: () => Effect.die("unused integration.oauth.cancel"), }, reload: integration.reload, connection: { diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index dfb0e82d0a..1de22a03dd 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -124,13 +124,17 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { yield* addPlugin() const integrations = yield* Integration.Service - const attempt = yield* integrations.connection.oauth({ - integrationID: Integration.ID.make("opencode"), + const integrationID = Integration.ID.make("opencode") + const attempt = yield* integrations.oauth.connect({ + integrationID, methodID: Integration.MethodID.make("device"), inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` }, }) expect(attempt.url).toBe(`${server.url.origin}/verify`) - yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete") + yield* eventually( + integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }), + (status) => status.status === "complete", + ) expect(requests).toContain("POST /console/auth/device/code") expect(requests).toContain("POST /console/auth/device/token") @@ -147,8 +151,8 @@ describe("OpencodePlugin", () => { it.effect("rejects non-HTTP OpenCode servers", () => Effect.gen(function* () { yield* addPlugin() - const error = yield* (yield* Integration.Service).connection - .oauth({ + const error = yield* (yield* Integration.Service).oauth + .connect({ integrationID: Integration.ID.make("opencode"), methodID: Integration.MethodID.make("device"), inputs: { server: "ftp://console.example.com" }, diff --git a/packages/protocol/src/groups/integration.ts b/packages/protocol/src/groups/integration.ts index a6ec4d5282..f0cc2b4b77 100644 --- a/packages/protocol/src/groups/integration.ts +++ b/packages/protocol/src/groups/integration.ts @@ -58,7 +58,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") ), ) .add( - HttpApiEndpoint.post("integration.connect.oauth", "/api/integration/:integrationID/connect/oauth", { + HttpApiEndpoint.post("integration.oauth.connect", "/api/integration/:integrationID/connect/oauth", { params: { integrationID: Integration.ID }, query: LocationQuery, payload: Schema.Struct({ @@ -72,54 +72,58 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration") .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.integration.connect.oauth", + identifier: "v2.integration.oauth.connect", summary: "Begin OAuth connection", description: "Start an OAuth attempt and return the authorization details.", }), ), ) .add( - HttpApiEndpoint.get("integration.attempt.status", "/api/integration/attempt/:attemptID", { - params: { attemptID: Integration.AttemptID }, + HttpApiEndpoint.get("integration.oauth.status", "/api/integration/:integrationID/connect/oauth/:attemptID", { + params: { integrationID: Integration.ID, attemptID: Integration.AttemptID }, query: LocationQuery, success: Location.response(Integration.AttemptStatus), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.integration.attempt.status", + identifier: "v2.integration.oauth.status", summary: "Get OAuth attempt status", description: "Poll the current status of an OAuth attempt.", }), ), ) .add( - HttpApiEndpoint.post("integration.attempt.complete", "/api/integration/attempt/:attemptID/complete", { - params: { attemptID: Integration.AttemptID }, - query: LocationQuery, - payload: Schema.Struct({ code: Schema.optional(Schema.String) }), - success: HttpApiSchema.NoContent, - error: InvalidRequestError, - }) + HttpApiEndpoint.post( + "integration.oauth.complete", + "/api/integration/:integrationID/connect/oauth/:attemptID/complete", + { + params: { integrationID: Integration.ID, attemptID: Integration.AttemptID }, + query: LocationQuery, + payload: Schema.Struct({ code: Schema.optional(Schema.String) }), + success: HttpApiSchema.NoContent, + error: InvalidRequestError, + }, + ) .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.integration.attempt.complete", + identifier: "v2.integration.oauth.complete", summary: "Complete OAuth connection", description: "Complete a code-based OAuth attempt and store the resulting credential.", }), ), ) .add( - HttpApiEndpoint.delete("integration.attempt.cancel", "/api/integration/attempt/:attemptID", { - params: { attemptID: Integration.AttemptID }, + HttpApiEndpoint.delete("integration.oauth.cancel", "/api/integration/:integrationID/connect/oauth/:attemptID", { + params: { integrationID: Integration.ID, attemptID: Integration.AttemptID }, query: LocationQuery, success: HttpApiSchema.NoContent, }) .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.integration.attempt.cancel", + identifier: "v2.integration.oauth.cancel", summary: "Cancel OAuth connection", description: "Cancel an OAuth attempt and release its resources.", }), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 32bc2d2074..43154a176f 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -294,20 +294,20 @@ import type { V2HealthGetResponses, V2HealthStopErrors, V2HealthStopResponses, - V2IntegrationAttemptCancelErrors, - V2IntegrationAttemptCancelResponses, - V2IntegrationAttemptCompleteErrors, - V2IntegrationAttemptCompleteResponses, - V2IntegrationAttemptStatusErrors, - V2IntegrationAttemptStatusResponses, V2IntegrationConnectKeyErrors, V2IntegrationConnectKeyResponses, - V2IntegrationConnectOauthErrors, - V2IntegrationConnectOauthResponses, V2IntegrationGetErrors, V2IntegrationGetResponses, V2IntegrationListErrors, V2IntegrationListResponses, + V2IntegrationOauthCancelErrors, + V2IntegrationOauthCancelResponses, + V2IntegrationOauthCompleteErrors, + V2IntegrationOauthCompleteResponses, + V2IntegrationOauthConnectErrors, + V2IntegrationOauthConnectResponses, + V2IntegrationOauthStatusErrors, + V2IntegrationOauthStatusResponses, V2LocationGetErrors, V2LocationGetResponses, V2McpListErrors, @@ -6827,13 +6827,15 @@ export class Connect extends HeyApiClient { }, }) } +} +export class Oauth2 extends HeyApiClient { /** * Begin OAuth connection * * Start an OAuth attempt and return the authorization details. */ - public oauth( + public connect( parameters: { integrationID: string location?: { @@ -6863,8 +6865,8 @@ export class Connect extends HeyApiClient { ], ) return (options?.client ?? this.client).post< - V2IntegrationConnectOauthResponses, - V2IntegrationConnectOauthErrors, + V2IntegrationOauthConnectResponses, + V2IntegrationOauthConnectErrors, ThrowOnError >({ url: "/api/integration/{integrationID}/connect/oauth", @@ -6877,9 +6879,7 @@ export class Connect extends HeyApiClient { }, }) } -} -export class Attempt extends HeyApiClient { /** * Cancel OAuth connection * @@ -6887,6 +6887,7 @@ export class Attempt extends HeyApiClient { */ public cancel( parameters: { + integrationID: string attemptID: string location?: { directory?: string | null @@ -6900,6 +6901,7 @@ export class Attempt extends HeyApiClient { [ { args: [ + { in: "path", key: "integrationID" }, { in: "path", key: "attemptID" }, { in: "query", key: "location" }, ], @@ -6907,11 +6909,11 @@ export class Attempt extends HeyApiClient { ], ) return (options?.client ?? this.client).delete< - V2IntegrationAttemptCancelResponses, - V2IntegrationAttemptCancelErrors, + V2IntegrationOauthCancelResponses, + V2IntegrationOauthCancelErrors, ThrowOnError >({ - url: "/api/integration/attempt/{attemptID}", + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}", ...options, ...params, }) @@ -6924,6 +6926,7 @@ export class Attempt extends HeyApiClient { */ public status( parameters: { + integrationID: string attemptID: string location?: { directory?: string | null @@ -6937,6 +6940,7 @@ export class Attempt extends HeyApiClient { [ { args: [ + { in: "path", key: "integrationID" }, { in: "path", key: "attemptID" }, { in: "query", key: "location" }, ], @@ -6944,11 +6948,11 @@ export class Attempt extends HeyApiClient { ], ) return (options?.client ?? this.client).get< - V2IntegrationAttemptStatusResponses, - V2IntegrationAttemptStatusErrors, + V2IntegrationOauthStatusResponses, + V2IntegrationOauthStatusErrors, ThrowOnError >({ - url: "/api/integration/attempt/{attemptID}", + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}", ...options, ...params, }) @@ -6961,6 +6965,7 @@ export class Attempt extends HeyApiClient { */ public complete( parameters: { + integrationID: string attemptID: string location?: { directory?: string | null @@ -6975,6 +6980,7 @@ export class Attempt extends HeyApiClient { [ { args: [ + { in: "path", key: "integrationID" }, { in: "path", key: "attemptID" }, { in: "query", key: "location" }, { in: "body", key: "code" }, @@ -6983,11 +6989,11 @@ export class Attempt extends HeyApiClient { ], ) return (options?.client ?? this.client).post< - V2IntegrationAttemptCompleteResponses, - V2IntegrationAttemptCompleteErrors, + V2IntegrationOauthCompleteResponses, + V2IntegrationOauthCompleteErrors, ThrowOnError >({ - url: "/api/integration/attempt/{attemptID}/complete", + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}/complete", ...options, ...params, headers: { @@ -7060,9 +7066,9 @@ export class Integration extends HeyApiClient { return (this._connect ??= new Connect({ client: this.client })) } - private _attempt?: Attempt - get attempt(): Attempt { - return (this._attempt ??= new Attempt({ client: this.client })) + private _oauth?: Oauth2 + get oauth(): Oauth2 { + return (this._oauth ??= new Oauth2({ client: this.client })) } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 94c6f4cde2..051aff88a6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -16846,7 +16846,7 @@ export type V2IntegrationConnectKeyResponses = { export type V2IntegrationConnectKeyResponse = V2IntegrationConnectKeyResponses[keyof V2IntegrationConnectKeyResponses] -export type V2IntegrationConnectOauthData = { +export type V2IntegrationOauthConnectData = { body: { methodID: string inputs: { @@ -16866,7 +16866,7 @@ export type V2IntegrationConnectOauthData = { url: "/api/integration/{integrationID}/connect/oauth" } -export type V2IntegrationConnectOauthErrors = { +export type V2IntegrationOauthConnectErrors = { /** * InvalidRequestError */ @@ -16877,9 +16877,9 @@ export type V2IntegrationConnectOauthErrors = { 401: UnauthorizedError } -export type V2IntegrationConnectOauthError = V2IntegrationConnectOauthErrors[keyof V2IntegrationConnectOauthErrors] +export type V2IntegrationOauthConnectError = V2IntegrationOauthConnectErrors[keyof V2IntegrationOauthConnectErrors] -export type V2IntegrationConnectOauthResponses = { +export type V2IntegrationOauthConnectResponses = { /** * Success */ @@ -16889,12 +16889,13 @@ export type V2IntegrationConnectOauthResponses = { } } -export type V2IntegrationConnectOauthResponse = - V2IntegrationConnectOauthResponses[keyof V2IntegrationConnectOauthResponses] +export type V2IntegrationOauthConnectResponse = + V2IntegrationOauthConnectResponses[keyof V2IntegrationOauthConnectResponses] -export type V2IntegrationAttemptCancelData = { +export type V2IntegrationOauthCancelData = { body?: never path: { + integrationID: string attemptID: string } query?: { @@ -16903,10 +16904,10 @@ export type V2IntegrationAttemptCancelData = { workspace?: string | null } | null } - url: "/api/integration/attempt/{attemptID}" + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}" } -export type V2IntegrationAttemptCancelErrors = { +export type V2IntegrationOauthCancelErrors = { /** * InvalidRequestError */ @@ -16917,21 +16918,22 @@ export type V2IntegrationAttemptCancelErrors = { 401: UnauthorizedError } -export type V2IntegrationAttemptCancelError = V2IntegrationAttemptCancelErrors[keyof V2IntegrationAttemptCancelErrors] +export type V2IntegrationOauthCancelError = V2IntegrationOauthCancelErrors[keyof V2IntegrationOauthCancelErrors] -export type V2IntegrationAttemptCancelResponses = { +export type V2IntegrationOauthCancelResponses = { /** * */ 204: void } -export type V2IntegrationAttemptCancelResponse = - V2IntegrationAttemptCancelResponses[keyof V2IntegrationAttemptCancelResponses] +export type V2IntegrationOauthCancelResponse = + V2IntegrationOauthCancelResponses[keyof V2IntegrationOauthCancelResponses] -export type V2IntegrationAttemptStatusData = { +export type V2IntegrationOauthStatusData = { body?: never path: { + integrationID: string attemptID: string } query?: { @@ -16940,10 +16942,10 @@ export type V2IntegrationAttemptStatusData = { workspace?: string | null } | null } - url: "/api/integration/attempt/{attemptID}" + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}" } -export type V2IntegrationAttemptStatusErrors = { +export type V2IntegrationOauthStatusErrors = { /** * InvalidRequestError */ @@ -16954,9 +16956,9 @@ export type V2IntegrationAttemptStatusErrors = { 401: UnauthorizedError } -export type V2IntegrationAttemptStatusError = V2IntegrationAttemptStatusErrors[keyof V2IntegrationAttemptStatusErrors] +export type V2IntegrationOauthStatusError = V2IntegrationOauthStatusErrors[keyof V2IntegrationOauthStatusErrors] -export type V2IntegrationAttemptStatusResponses = { +export type V2IntegrationOauthStatusResponses = { /** * Success */ @@ -16966,14 +16968,15 @@ export type V2IntegrationAttemptStatusResponses = { } } -export type V2IntegrationAttemptStatusResponse = - V2IntegrationAttemptStatusResponses[keyof V2IntegrationAttemptStatusResponses] +export type V2IntegrationOauthStatusResponse = + V2IntegrationOauthStatusResponses[keyof V2IntegrationOauthStatusResponses] -export type V2IntegrationAttemptCompleteData = { +export type V2IntegrationOauthCompleteData = { body: { code?: string | null } path: { + integrationID: string attemptID: string } query?: { @@ -16982,10 +16985,10 @@ export type V2IntegrationAttemptCompleteData = { workspace?: string | null } | null } - url: "/api/integration/attempt/{attemptID}/complete" + url: "/api/integration/{integrationID}/connect/oauth/{attemptID}/complete" } -export type V2IntegrationAttemptCompleteErrors = { +export type V2IntegrationOauthCompleteErrors = { /** * InvalidRequestError */ @@ -16996,18 +16999,17 @@ export type V2IntegrationAttemptCompleteErrors = { 401: UnauthorizedError } -export type V2IntegrationAttemptCompleteError = - V2IntegrationAttemptCompleteErrors[keyof V2IntegrationAttemptCompleteErrors] +export type V2IntegrationOauthCompleteError = V2IntegrationOauthCompleteErrors[keyof V2IntegrationOauthCompleteErrors] -export type V2IntegrationAttemptCompleteResponses = { +export type V2IntegrationOauthCompleteResponses = { /** * */ 204: void } -export type V2IntegrationAttemptCompleteResponse = - V2IntegrationAttemptCompleteResponses[keyof V2IntegrationAttemptCompleteResponses] +export type V2IntegrationOauthCompleteResponse = + V2IntegrationOauthCompleteResponses[keyof V2IntegrationOauthCompleteResponses] export type V2McpListData = { body?: never diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 6c29d58776..826eee9eb4 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -48,12 +48,12 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" }), ) .handle( - "integration.connect.oauth", + "integration.oauth.connect", Effect.fn(function* (ctx) { const service = yield* Integration.Service return yield* response( authorize( - service.connection.oauth({ + service.oauth.connect({ integrationID: ctx.params.integrationID, methodID: ctx.payload.methodID, inputs: ctx.payload.inputs, @@ -64,39 +64,53 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" }), ) .handle( - "integration.attempt.status", + "integration.oauth.status", Effect.fn(function* (ctx) { const service = yield* Integration.Service - return yield* response(service.attempt.status(ctx.params.attemptID)) + return yield* response( + service.oauth.status({ + integrationID: ctx.params.integrationID, + attemptID: ctx.params.attemptID, + }), + ) }), ) .handle( - "integration.attempt.complete", + "integration.oauth.complete", Effect.fn(function* (ctx) { const service = yield* Integration.Service - yield* service.attempt.complete({ attemptID: ctx.params.attemptID, code: ctx.payload.code }).pipe( - Effect.mapError( - (error) => - new InvalidRequestError({ - message: - error._tag === "Integration.CodeRequired" - ? "Authorization code is required" - : "Authentication failed", - kind: - error._tag === "Integration.CodeRequired" - ? "integration_code_required" - : "integration_authorization", - }), - ), - ) + yield* service.oauth + .complete({ + integrationID: ctx.params.integrationID, + attemptID: ctx.params.attemptID, + code: ctx.payload.code, + }) + .pipe( + Effect.mapError( + (error) => + new InvalidRequestError({ + message: + error._tag === "Integration.CodeRequired" + ? "Authorization code is required" + : "Authentication failed", + kind: + error._tag === "Integration.CodeRequired" + ? "integration_code_required" + : "integration_authorization", + }), + ), + ) return HttpApiSchema.NoContent.make() }), ) .handle( - "integration.attempt.cancel", + "integration.oauth.cancel", Effect.fn(function* (ctx) { const service = yield* Integration.Service - yield* service.attempt.cancel(ctx.params.attemptID) + yield* service.oauth.cancel({ + integrationID: ctx.params.integrationID, + attemptID: ctx.params.attemptID, + }) return HttpApiSchema.NoContent.make() }), ) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 218e2cd220..ad4240f0c0 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1,8 +1,8 @@ import { TextAttributes } from "@opentui/core" import type { ConnectionInfo, - IntegrationConnectOauthOutput, IntegrationInfo, + IntegrationOauthConnectOutput, IntegrationOAuthMethod, } from "@opencode-ai/client" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" @@ -27,7 +27,7 @@ const INTEGRATION_PRIORITY: Record = { } type ConnectMethod = Exclude -type IntegrationAttempt = IntegrationConnectOauthOutput["data"] +type IntegrationAttempt = IntegrationOauthConnectOutput["data"] type OnIntegrationConnected = (providerID?: string) => void export function integrationOptions(list: IntegrationInfo[]) { @@ -227,8 +227,8 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void client.api.integration.connect - .oauth({ + void client.api.integration.oauth + .connect({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -297,8 +297,8 @@ function OAuthAuto(props: { })) const poll = () => { - void client.api.integration.attempt - .status({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.oauth + .status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -324,7 +324,11 @@ function OAuthAuto(props: { onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return - void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.oauth.cancel({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: location(data), + }) }) return ( @@ -354,7 +358,11 @@ function OAuthCode(props: { onCleanup(() => { if (settled) return - void client.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void client.api.integration.oauth.cancel({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: location(data), + }) }) return ( @@ -363,8 +371,13 @@ function OAuthCode(props: { placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void client.api.integration.attempt - .complete({ attemptID: props.attempt.attemptID, location: location(data), code }) + void client.api.integration.oauth + .complete({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: location(data), + code, + }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected)