Auto-creating Deployments on First Launch

Copy Markdown View Source

In the standard LTI flow, registrations and deployments are both created out-of-band before the first launch. But many tools don't need per-deployment configuration. Their configuration is per platform (registration), and they only record deployments. For these tools, you can auto-create deployments the first time a new deployment_id appears in a launch.

This works because get_deployment/3 runs after JWT signature verification and nonce validation, so the deployment_id is already verified by the time your adapter receives it.

Storage adapter

The simplest approach is to make get_deployment/3 upsert. The first launch creates the deployment row, and subsequent launches find it. No controller changes needed.

@impl true
def get_deployment(%PlatformRegistration{id: registration_id}, deployment_id, _opts) do
  %PlatformDeployment{registration_id: registration_id, deployment_id: deployment_id}
  |> Repo.insert(on_conflict: :nothing, conflict_target: [:registration_id, :deployment_id])

  case Repo.get_by(PlatformDeployment,
         registration_id: registration_id,
         deployment_id: deployment_id
       ) do
    nil -> {:error, :not_found}
    record -> {:ok, record}
  end
end

The insert-then-select pattern handles the race condition where two concurrent launches for the same new deployment both call get_deployment/3 at the same time. on_conflict: :nothing means the second insert silently succeeds without duplicating the row, and both calls find the same record on the subsequent select.

Migration

Your platform_deployments table needs a unique index for the upsert's conflict target.

create unique_index(:platform_deployments, [:registration_id, :deployment_id])

Controller

If deployments have meaning in your domain (per-deployment settings, onboarding flows, approval steps), handle creation in the controller. This lets you show a registration form, collect extra information, or require admin approval before the deployment is active.

def launch(conn, params) do
  state = get_session(conn, :lti_state)

  case Ltix.handle_callback(params, state) do
    {:ok, context} ->
      conn
      |> delete_session(:lti_state)
      |> render(:launch, context: context)

    {:error, %Ltix.Errors.Invalid.DeploymentNotFound{deployment_id: id}} ->
      # Create the deployment, then ask the user to re-launch.
      # We can't retry handle_callback here because the nonce was
      # already used (see note below).
      MyApp.Deployments.create!(id)

      conn
      |> delete_session(:lti_state)
      |> render(:deployment_created,
        message: "Setup complete. Return to your LMS and launch again."
      )

    {:error, error} ->
      conn
      |> put_status(400)
      |> text("Launch failed: #{Exception.message(error)}")
  end
end

For an onboarding flow where you need to collect information before creating the deployment, use this clause.

{:error, %Ltix.Errors.Invalid.DeploymentNotFound{deployment_id: id}} ->
  conn
  |> put_session(:pending_deployment_id, id)
  |> delete_session(:lti_state)
  |> redirect(to: ~p"/lti/onboard")

Nonce is used before deployment lookup

handle_callback/3 records the nonce as used before looking up the deployment. When the call fails with DeploymentNotFound, the nonce is already used. You cannot retry handle_callback/3 with the same params. The user must re-launch from the platform.

Which approach to use

Storage adapterController
Best forDeployments are only recordsDeployments have domain meaning
Controller changesNoneMatch on DeploymentNotFound
User experienceFirst launch succeeds with no extra stepCan show onboarding UI
ComplexityLowMedium