Bladeren bron

refactor(api): scope oauth operations by integration

Dax Raad 1 maand geleden
bovenliggende
commit
a772767e9b

+ 6 - 5
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)

+ 7 - 6
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<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
   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
   })

+ 21 - 22
packages/client/src/effect/api/api.ts

@@ -419,7 +419,7 @@ export type IntegrationConnectKeyOperation<E = never> = (
   input: Endpoint10_2Input,
 ) => Effect.Effect<Endpoint10_2Output, E>
 
-type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
+type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.oauth.connect"]>[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<ReturnType<RawClient["server.integration"]["integration.connect.oauth"]>>
-export type IntegrationConnectOauthOperation<E = never> = (
+export type Endpoint10_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.connect"]>>
+export type IntegrationOauthConnectOperation<E = never> = (
   input: Endpoint10_3Input,
 ) => Effect.Effect<Endpoint10_3Output, E>
 
-type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
+type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.oauth.status"]>[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<ReturnType<RawClient["server.integration"]["integration.attempt.status"]>>
-export type IntegrationAttemptStatusOperation<E = never> = (
+export type Endpoint10_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.status"]>>
+export type IntegrationOauthStatusOperation<E = never> = (
   input: Endpoint10_4Input,
 ) => Effect.Effect<Endpoint10_4Output, E>
 
-type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
+type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.oauth.complete"]>[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<RawClient["server.integration"]["integration.attempt.complete"]>
->
-export type IntegrationAttemptCompleteOperation<E = never> = (
+export type Endpoint10_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.complete"]>>
+export type IntegrationOauthCompleteOperation<E = never> = (
   input: Endpoint10_5Input,
 ) => Effect.Effect<Endpoint10_5Output, E>
 
-type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
+type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.oauth.cancel"]>[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<ReturnType<RawClient["server.integration"]["integration.attempt.cancel"]>>
-export type IntegrationAttemptCancelOperation<E = never> = (
+export type Endpoint10_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.oauth.cancel"]>>
+export type IntegrationOauthCancelOperation<E = never> = (
   input: Endpoint10_6Input,
 ) => Effect.Effect<Endpoint10_6Output, E>
 
 export interface IntegrationApi<E = never> {
   readonly list: IntegrationListOperation<E>
   readonly get: IntegrationGetOperation<E>
-  readonly connect: {
-    readonly key: IntegrationConnectKeyOperation<E>
-    readonly oauth: IntegrationConnectOauthOperation<E>
-  }
-  readonly attempt: {
-    readonly status: IntegrationAttemptStatusOperation<E>
-    readonly complete: IntegrationAttemptCompleteOperation<E>
-    readonly cancel: IntegrationAttemptCancelOperation<E>
+  readonly connect: { readonly key: IntegrationConnectKeyOperation<E> }
+  readonly oauth: {
+    readonly connect: IntegrationOauthConnectOperation<E>
+    readonly status: IntegrationOauthStatusOperation<E>
+    readonly complete: IntegrationOauthCompleteOperation<E>
+    readonly cancel: IntegrationOauthCancelOperation<E>
   }
 }
 

+ 21 - 13
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<RawClient["server.integration"]["integration.connect.oauth"]>[0]
+type Endpoint10_3Request = Parameters<RawClient["server.integration"]["integration.oauth.connect"]>[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<RawClient["server.integration"]["integration.attempt.status"]>[0]
+type Endpoint10_4Request = Parameters<RawClient["server.integration"]["integration.oauth.status"]>[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<RawClient["server.integration"]["integration.attempt.complete"]>[0]
+type Endpoint10_5Request = Parameters<RawClient["server.integration"]["integration.oauth.complete"]>[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<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
+type Endpoint10_6Request = Parameters<RawClient["server.integration"]["integration.oauth.cancel"]>[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<RawClient["server.mcp"]["mcp.list"]>[0]

+ 21 - 21
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<IntegrationConnectOauthOutput>(
+      },
+      oauth: {
+        connect: (input: IntegrationOauthConnectInput, requestOptions?: RequestOptions) =>
+          request<IntegrationOauthConnectOutput>(
             {
               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<IntegrationAttemptStatusOutput>(
+        status: (input: IntegrationOauthStatusInput, requestOptions?: RequestOptions) =>
+          request<IntegrationOauthStatusOutput>(
             {
               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<IntegrationAttemptCompleteOutput>(
+        complete: (input: IntegrationOauthCompleteInput, requestOptions?: RequestOptions) =>
+          request<IntegrationOauthCompleteOutput>(
             {
               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<IntegrationAttemptCancelOutput>(
+        cancel: (input: IntegrationOauthCancelInput, requestOptions?: RequestOptions) =>
+          request<IntegrationOauthCancelOutput>(
             {
               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],

+ 14 - 11
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?: {

+ 3 - 3
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"])

+ 94 - 73
packages/core/src/integration.ts

@@ -159,17 +159,6 @@ export interface Interface extends State.Transformable<Draft> {
       /** User-facing label for the stored credential. */
       readonly label?: string
     }) => Effect.Effect<void, AuthorizationError>
-    /** 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<Attempt, AuthorizationError>
     /** Updates a stored credential exposed as a connection. */
     readonly update: (
       credentialID: Credential.ID,
@@ -178,18 +167,27 @@ export interface Interface extends State.Transformable<Draft> {
     /** Removes a stored credential connection. */
     readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
   }
-  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<Attempt, AuthorizationError>
     /** Returns the current state of an OAuth attempt. */
-    readonly status: (attemptID: AttemptID) => Effect.Effect<AttemptStatus>
+    readonly status: (input: {
+      readonly integrationID: ID
+      readonly attemptID: AttemptID
+    }) => Effect.Effect<AttemptStatus>
     /** 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<void, CodeRequiredError | AuthorizationError>
     /** Cancels an attempt and releases its resources. */
-    readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
+    readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
   }
 }
 
@@ -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)

+ 21 - 7
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: {

+ 15 - 9
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),
                   }),
                 ),

+ 20 - 15
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.oauth.cancel({
+        integrationID: Integration.ID.make("other"),
+        attemptID: attempt.attemptID,
+      })
       expect(closed).toBe(false)
-      yield* integrations.attempt.cancel(attempt.attemptID)
+      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,
       })

+ 2 - 2
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,

+ 10 - 10
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: {

+ 9 - 5
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" },

+ 20 - 16
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.",
         }),

+ 31 - 25
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<ThrowOnError extends boolean = false>(
+  public connect<ThrowOnError extends boolean = false>(
     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<ThrowOnError extends boolean = false>(
     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<ThrowOnError extends boolean = false>(
     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<ThrowOnError extends boolean = false>(
     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 }))
   }
 }
 

+ 30 - 28
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 = {
   /**
    * <No Content>
    */
   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 = {
   /**
    * <No Content>
    */
   204: void
 }
 
-export type V2IntegrationAttemptCompleteResponse =
-  V2IntegrationAttemptCompleteResponses[keyof V2IntegrationAttemptCompleteResponses]
+export type V2IntegrationOauthCompleteResponse =
+  V2IntegrationOauthCompleteResponses[keyof V2IntegrationOauthCompleteResponses]
 
 export type V2McpListData = {
   body?: never

+ 36 - 22
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()
         }),
       )

+ 23 - 10
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<string, number> = {
 }
 
 type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
-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)