# Forge v2 to v3

This guide walks through migrating a Forge 2.x application to Forge 3.0. The migration covers .NET 10, Aspire 13, Entra ID authentication, Terraform restructuring, and pipeline updates.

> ⚠️ **Important**: This guide assumes you have already completed the [Forge 1.x → 2.x migration](forge-v1-to-v2.md). If your application is still on Forge 1.x, complete that migration first before proceeding.

> 🧪 **Test-Tools Repository?** If you're migrating a test-tools repository (for managing test users and non-prod role assignments), see the [Test Tools Migration Guide](test-tools-v2-to-v3.md) instead.

[TOC]

---

## 📋 Prerequisites

- [ ] **Complete Forge 1.x → 2.x migration** (if not already done) — See [Migration Guide: Forge v1 to v2](forge-v1-to-v2.md)

- [ ] **Install .NET 10 SDK** — Download from [dotnet.microsoft.com/download/dotnet/10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0)

```powershell
dotnet --version  # Verify: 10.0.x
```

- [ ] **Update SAIF Tools** (if not already on latest version)

```powershell
# Check current versions first
saif --version   # Should be 3.0.0+
aspire --version # Should be 13.1.0+

# If versions are outdated, update the CLI itself, then its tools/templates
saif doctor fix --self
Start-Sleep -Seconds 20  # Wait for background updates to complete
saif doctor fix
Start-Sleep -Seconds 20  # Wait for background updates to complete

# Verify updates
saif --version
aspire --version
```

- [ ] **Install dotnet-outdated-tool** ⚠️ **REQUIRED for migration**

```powershell
# Check if already installed
dotnet outdated --version

# If not installed or outdated, install/update it
dotnet tool install -g dotnet-outdated-tool
# Or if already installed: dotnet tool update -g dotnet-outdated-tool
```

> 🔧 **Why this is critical**: The `dotnet-outdated` tool automatically updates all package references to their latest compatible versions, saving significant time and reducing errors during the migration. Without it, you'll need to manually update dozens of package versions across multiple project files.

---

## 1️⃣ .NET 10 Upgrade

- [ ] Update the `<TargetFramework>` in all `.csproj` files from `net9.0` to `net10.0`

### Before

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <ContainerFamily>noble-chiseled</ContainerFamily>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Aspire.Azure.Messaging.ServiceBus" Version="9.4.2" />
    <PackageReference Include="SAIF.Platform" Version="1.0.*" />
    <PackageReference Include="SAIF.Platform.Authentication.AspNetCore" Version="1.0.*" />
    <PackageReference Include="SAIF.Platform.Azure" Version="2.*" />
  </ItemGroup>
</Project>
```

### After

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <ContainerFamily>noble-chiseled-extra</ContainerFamily>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Aspire.Azure.Messaging.ServiceBus" Version="13.1.0" />
    <PackageReference Include="SAIF.Platform" Version="3.0.0" />
    <PackageReference Include="SAIF.Platform.Authentication.AspNetCore" Version="3.0.0" />
    <PackageReference Include="SAIF.Platform.Azure" Version="3.0.0" />
  </ItemGroup>
</Project>
```

### Files to Update

- `src/**/*.csproj`

---

## 2️⃣ Package Updates

> ⚠️ **IMPORTANT**: This is the most critical step in the migration. The `dotnet-outdated` tool will automatically update all your package references to Forge 3.0 compatible versions.

- [ ] **Run `dotnet-outdated` to update all packages** (from your repository root):

```powershell
# First, view all outdated packages to see what will change
dotnet outdated

# Update all packages to latest versions
# This updates SAIF.Platform, Aspire, and all dependencies
dotnet outdated -u

# Verify the updates were applied
dotnet restore
dotnet build
```

> 💡 **Tip**: If `dotnet-outdated -u` fails or skips some packages, try running it multiple times. Some packages may have dependencies that need to be updated in a specific order.

- [ ] Run `saif doctor fix` to ensure Aspire tooling is current:

```powershell
saif doctor fix
```

### Verify Package Versions

After running `dotnet-outdated -u`, verify that your packages have been updated to the expected versions. You can use the following command to check specific package versions:

```powershell
# Check a specific package version
dotnet package search <package-name> --exact-match --take 1
```

Open your `.csproj` files and confirm the following minimum versions:

| Package                                   | Minimum Version |
| ----------------------------------------- | --------------- |
| `SAIF.Platform`                           | `3.0.0+`        |
| `SAIF.Platform.Authentication.AspNetCore` | `3.0.0+`        |
| `SAIF.Platform.Azure`                     | `3.0.0+`        |
| `SAIF.Platform.Kiota.HttpClientLibrary`   | `3.0.0+`        |
| `SAIF.Platform.Aspire.Hosting`            | `3.0.0+`        |
| `Aspire.Hosting.AppHost`                  | `13.1.0+`       |
| `Aspire.Hosting.Azure.CosmosDB`           | `13.1.0+`       |
| `Aspire.Hosting.Azure.ServiceBus`         | `13.1.0+`       |
| `Aspire.Azure.Messaging.ServiceBus`       | `13.1.0+`       |
| `Aspire.Hosting.JavaScript`               | `13.1.0+`       |
| `Microsoft.Extensions.Http.Resilience`    | `10.1.0+`       |
| `Microsoft.Extensions.ServiceDiscovery`   | `10.1.0+`       |
| `Microsoft.EntityFrameworkCore.Design`    | `10.0.0+`       |
| `Microsoft.Extensions.Hosting`            | `10.0.0+`       |
| `Microsoft.NET.Test.Sdk`                  | `18.0.1+`       |

> ℹ️ **Note**: If any package wasn't updated to the expected version, run `dotnet outdated -u` again or manually update the version in your `.csproj` file.

### Packages to Remove

The following packages are no longer needed and should be removed if present in your project:

| Package                                  | Reason                                  |
| ---------------------------------------- | --------------------------------------- |
| `Aspire.Hosting.NodeJs`                  | Replaced by `Aspire.Hosting.JavaScript` |
| `SAIF.Platform.ApiDescription.Client`    | TypeSpec now handles OpenAPI generation |
| `Microsoft.AspNetCore.OpenApi`           | TypeSpec now handles OpenAPI generation |
| `Microsoft.AspNetCore.Http.Abstractions` | No longer needed for OpenAPI            |
| `Microsoft.OpenApi`                      | TypeSpec now handles OpenAPI generation |
| `Swashbuckle.AspNetCore.SwaggerGen`      | TypeSpec now handles OpenAPI generation |

- [ ] **Remove deprecated packages** from your `.csproj` files:

```xml
<!-- Remove these packages if present -->
<PackageReference Include="Aspire.Hosting.NodeJs" />
<PackageReference Include="SAIF.Platform.ApiDescription.Client" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" />
```

- [ ] **Remove API Description code** - Delete `ApiDescription` and related OpenAPI code from `Program.cs`:

```csharp
// Remove these lines:
builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Remove app.MapOpenApi() and related middleware
```

> ℹ️ **Note**: OpenAPI specs are now generated from TypeSpec definitions during build. The generated spec is automatically deployed to Azure API Management.

### npm Package Updates (If Applicable)

If your project has a React frontend or TypeSpec definitions, update the SAIF platform npm packages:

- [ ] **Update SAIF platform npm packages** to version 3.0.0+:

```powershell
# For React frontends
cd src/<project>.Frontend
npm install @saif/platform@^3.0.0 @saif/platform-react@^3.0.0

# For TypeSpec projects
cd src/<project>.TypeSpec
npm install @saif/platform-typespec@^3.0.0
```

**Verify in `package.json`:**

| Package                   | Minimum Version | Used In           |
| ------------------------- | --------------- | ----------------- |
| `@saif/platform`          | `3.0.0+`        | React frontends   |
| `@saif/platform-react`    | `3.0.0+`        | React frontends   |
| `@saif/platform-typespec` | `3.0.0+`        | TypeSpec projects |

> ℹ️ **Note**: These are primarily dependency updates for compatibility with Forge 3.0. No significant API changes are required in your frontend or TypeSpec code.

---

## 3️⃣ AppHost Changes

The AppHost project requires structural changes for Aspire 13.

### Before

```xml
<Project Sdk="Microsoft.NET.Sdk">

  <Sdk Name="Aspire.AppHost.Sdk" Version="9.4.0" />

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsAspireHost>true</IsAspireHost>
    <KeycloakAutoGenerate>true</KeycloakAutoGenerate>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.0" />
    <PackageReference Include="Aspire.Hosting.Azure.CosmosDB" Version="9.4.0" />
    <PackageReference Include="Aspire.Hosting.Azure.ServiceBus" Version="9.4.2" />
    <PackageReference Include="Aspire.Hosting.NodeJs" Version="9.4.0" />
    <PackageReference Include="SAIF.Platform.Aspire.Hosting" Version="1.0.9" />
  </ItemGroup>

</Project>
```

### After

```xml
<Project Sdk="Aspire.AppHost.Sdk/13.1.0">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.AppHost" Version="13.1.0" />
    <PackageReference Include="Aspire.Hosting.Azure.CosmosDB" Version="13.1.0" />
    <PackageReference Include="Aspire.Hosting.Azure.ServiceBus" Version="13.1.0" />
    <PackageReference Include="Aspire.Hosting.JavaScript" Version="13.1.0" />
    <PackageReference Include="SAIF.Platform.Aspire.Hosting" Version="3.0.0" />
  </ItemGroup>

</Project>
```

### Checklist

- [ ] **SDK declaration**: Move from `<Sdk Name="..." Version="..." />` to `<Project Sdk="Aspire.AppHost.Sdk/13.1.0">`
- [ ] **Remove `IsAspireHost`**: No longer needed (implicit)
- [ ] **Replace `Aspire.Hosting.NodeJs`** with `Aspire.Hosting.JavaScript`

### JavaScript App Registration Changes

Aspire 13 replaces `AddNpmApp` with `AddJavaScriptApp`. Update your AppHost resource builder files:

#### Before

```csharp
var frontend = builder
    .AddNpmApp("FrontEndProjectId2", "../ApplicationName1.FrontEnd", scriptName: "dev")
    .WithEnvironment("BROWSER", "none")
    .WithHttpEndpoint(env: "PORT", port: 23000)
    .WithExternalHttpEndpoints();
```

#### After

```csharp
var frontend = builder
    .AddJavaScriptApp("FrontEndProjectId2", "../ApplicationName1.FrontEnd", "dev")
    .WithEnvironment("BROWSER", "none")
    .WithHttpEndpoint(env: "PORT", port: 23000)
    .WithExternalHttpEndpoints();
```

**Key Changes:**
- Replace `AddNpmApp` with `AddJavaScriptApp`
- Script name moves from named parameter `scriptName: "dev"` to third positional parameter `"dev"`
- If using `args` parameter, replace with `.WithArgs()` method call

---

## 4️⃣ Infrastructure Folder Restructuring

Forge 3.0 reorganizes the `infra/` folder structure for better separation of concerns.

### Before

```
infra/
├── app/                    # API infrastructure
├── auth/
│   ├── app/               # App-to-app auth (Okta)
│   └── user/              # User auth (Okta)
├── bootstrap/
├── okta-client/           # Okta client registration
└── web/
```

### After

```
infra/
├── api/                   # Renamed from app/
├── auth/
│   ├── corp/             # Internal auth (Entra ID) - NEW
│   └── ext/              # External auth (Okta)
│       ├── app/
│       ├── okta-client/
│       └── user/
├── bootstrap/
└── web/
```

### Checklist

Run these commands from your repository root:

- [ ] Rename `app/` to `api/`

```powershell
git mv ./infra/app/ ./infra/api/
```

- [ ] Create external auth directory

```powershell
New-Item -ItemType Directory -Path ./infra/auth/ext -Force
```

- [ ] Move Okta auth folders to `ext/`

```powershell
git mv ./infra/auth/app/ ./infra/auth/ext/app/
git mv ./infra/auth/user/ ./infra/auth/ext/user/
git mv ./infra/okta-client/ ./infra/auth/ext/okta-client/
```

- [ ] Create corporate auth directory for Entra ID

```powershell
New-Item -ItemType Directory -Path ./infra/auth/corp -Force
git add ./infra/auth/corp/
```

- [ ] Update TypeSpec config to reflect new OpenAPI output path

Since the `app/` folder was renamed to `api/`, update `tspconfig.yaml` to emit OpenAPI specs to the new location:

**Before (`src/<project>.TypeSpec/tspconfig.yaml`)**

```yaml
emit:
  - "@typespec/openapi3"
  - "@typespec/http-server-csharp"
options:
  "@typespec/openapi3":
    emitter-output-dir: "{project-root}/../../infra/app/openapi"
  "@typespec/http-server-csharp":
    emitter-output-dir: "{project-root}/../../src/<project>"
```

> ⚠️ **Frontend Reference**: If you have a React frontend, the npm script `generate:backend-client` in `package.json` will also reference this path. Update it from `../../infra/app/openapi/openapi.v1.yaml` to `../../infra/api/openapi/openapi.v1.yaml` when you move the folder.

**After**

```yaml
emit:
  - "@typespec/openapi3"
  - "@typespec/http-server-csharp"
options:
  "@typespec/openapi3":
    emitter-output-dir: "{project-root}/../../infra/api/openapi"
  "@typespec/http-server-csharp":
    emitter-output-dir: "{project-root}/../../src/<project>"
```

- [ ] Find and replace all remaining `infra/app` references

Use find-and-replace across your entire repository to catch any remaining references:

```powershell
# Search for remaining references (case-sensitive)
Get-ChildItem -Path . -Recurse -Include *.tf,*.yml,*.yaml,*.cs,*.csproj,*.json |
  Select-String "infra/app" -CaseSensitive

# PowerShell find and replace
Get-ChildItem -Path . -Recurse -Include *.tf,*.yml,*.yaml,*.cs,*.csproj,*.json | ForEach-Object {
    $content = Get-Content $_.FullName -Raw
    $newContent = $content -creplace 'infra/app', 'infra/api'
    if ($content -ne $newContent) {
        Set-Content $_.FullName -Value $newContent -NoNewline
        Write-Host "Updated: $($_.FullName)"
    }
}
```

> ⚠️ **Manual Review Required**: After running the find-and-replace, manually review the changes to ensure no unintended replacements were made (e.g., in comments or documentation that should remain as-is).

---

## 5️⃣ Terraform Variable Casing

- [ ] Update Terraform variables to `snake_case`

### Before (`infra/app/vars.yml`)

```yaml
ApplicationName: MyApp
ApplicationType: Api
ApiType: Experience
Owner: MyTeam
ProjectId: it-api-exp-myapp
```

### After (`infra/api/vars.yml`)

```yaml
application_name: MyApp
application_type: Api
api_type: Experience
owner: MyTeam
project_id: it-api-exp-myapp
```

### Before (`infra/app/app.generated.tf`)

```hcl
variable "Environment" {
  type        = string
  description = "The environment in which the resources are deployed"
}

variable "EnvironmentShortName" {
  type        = string
  description = "The short name of the environment"
  default     = ""
}

variable "BuildNumber" {
  type        = string
  description = "The build number of the pipeline that deployed the resources."
}

variable "IsProduction" {
  type        = bool
  description = "Is this a production environment?"
}

variable "Application_Secrets" {
  type        = map(string)
  description = "Secrets for your application from Azure Devops Library"
  default     = {}
}

locals {
  Variables = yamldecode(file("${path.module}/vars.yml"))
  API = {
    Name        = local.Variables.ApplicationName
    Type        = local.Variables.ApiType
    OpenApiFile = file("${path.module}/openapi/${var.OpenAPIFile}")
  }
}

module "saif-appservices" {
  source = "app.terraform.io/SAIFCorp/saif-apiservice/azure"

  Owner                = local.Variables.Owner
  ProjectId            = local.Variables.ProjectId
  Environment          = var.Environment
  EnvironmentShortName = var.EnvironmentShortName
  Tags                 = local.common_tags
  API                  = local.API
  IsProduction         = var.IsProduction
  Application_Settings = local.settings_map
  Application_Secrets  = var.Application_Secrets
}
```

### After (`infra/api/app.generated.tf`)

```hcl
variable "environment" {
  type        = string
  description = "The environment in which the resources are deployed"
}

variable "environment_short_name" {
  type        = string
  description = "The short name of the environment"
  default     = ""
}

variable "build_number" {
  type        = string
  description = "The build number of the pipeline that deployed the resources."
}

variable "is_production" {
  type        = bool
  description = "Is this a production environment?"
}

variable "application_secrets" {
  type        = map(string)
  description = "Secrets for your application from Azure Devops Library"
  default     = {}
}

locals {
  variables = yamldecode(file("${path.module}/vars.yml"))
  api = {
    name          = local.variables.application_name
    type          = local.variables.api_type
    open_api_file = file("${path.module}/openapi/${var.open_api_file}")
  }
}

module "saif-appservices" {
  source  = "app.terraform.io/SAIFCorp/saif-apiservice/azure"
  version = ">= 3.0.0, < 4.0.0"

  owner                = local.variables.owner
  project_id           = local.variables.project_id
  environment          = var.environment
  environment_short_name = var.environment_short_name
  tags                 = local.common_tags
  api                  = local.api
  is_production        = var.is_production
  application_settings = local.settings_map
  application_secrets  = var.application_secrets
}
```

### Variable Name Mapping

| Before (PascalCase)    | After (snake_case)       |
| ---------------------- | ------------------------ |
| `Environment`          | `environment`            |
| `EnvironmentShortName` | `environment_short_name` |
| `Tenant`               | `tenant`                 |
| `BuildNumber`          | `build_number`           |
| `IsProduction`         | `is_production`          |
| `Application_Secrets`  | `application_secrets`    |
| `OpenAPIFile`          | `open_api_file`          |
| `ProjectId`            | `project_id`             |
| `ApplicationName`      | `application_name`       |

### Bootstrap Terraform Updates

- [ ] Update `infra/bootstrap/bootstrap.generated.tf` to use snake_case variables and module version 3.0:

#### Before

```hcl
variable "ProjectId" {
  type        = string
  description = "The id of the project to create the resources in."
}

variable "Environment" {
  type        = string
  description = "The environment to deploy the resources in."
  default     = ""
}

module "bootstrapper" {
  source      = "app.terraform.io/SAIFCorp/bootstrapper/saif"
  version     = ">=1.0.0, <2.0.0"
  ProjectId   = var.ProjectId
  Environment = var.Environment
  HasWebApp   = try(local.features.has_web_app, false)
}
```

#### After

```hcl
variable "project_id" {
  type        = string
  description = "The id of the project to create the resources in."
}

variable "environment" {
  type        = string
  description = "The environment to deploy the resources in."
  default     = ""
}

module "bootstrapper" {
  source            = "app.terraform.io/SAIFCorp/bootstrapper/saif"
  version           = ">= 3.0.0, < 4.0.0"
  project_id        = var.project_id
  environment       = var.environment
  has_web_app       = try(local.features.has_web_app, false)
  has_subscriptions = try(local.features.has_subscriptions, false)
}
```

### Web App Terraform Updates

If your project has a web frontend (`infra/web/`), update the module and variables:

- [ ] Update `infra/web/web.generated.tf`:

| Variable Before        | Variable After           |
| ---------------------- | ------------------------ |
| `Environment`          | `environment`            |
| `EnvironmentShortName` | `environment_short_name` |
| `Tenant`               | `tenant`                 |
| `DeployedBy`           | `deployed_by`            |
| `BuildNumber`          | `build_number`           |
| `IsProduction`         | `is_production`          |
| `createStagingSlot`    | `create_staging_slot`    |

#### Before

```hcl
variable "Environment" {
  type        = string
  description = "The environment in which the resources are deployed"
}

variable "EnvironmentShortName" {
  type        = string
  description = "The short name of the environment"
  default     = ""
}

variable "Tenant" {
  type = string
}

variable "DeployedBy" {
  type = string
}

variable "BuildNumber" {
  type        = string
  description = "The build number of the pipeline that deployed the resources."
}

variable "IsProduction" {
  type        = bool
  description = "Is this a production environment?"
}

variable "createStagingSlot" {
  type        = bool
  description = "Whether to create a secondary slot for the application"
  default     = false
}

locals {
  Variables = yamldecode(file("${path.module}/vars.yml"))
  common_tags = {
    DeployedBy      = var.DeployedBy
    ApplicationName = local.Variables.ApplicationName
    BuildNumber     = var.BuildNumber
    TeamName        = local.Variables.Owner
  }
}

module "saif-webapp" {
  source               = "app.terraform.io/SAIFCorp/saif-webapp/azure"
  version              = ">=2.0.0, <3.0.0"
  Environment          = var.Environment
  Owner                = local.Variables.Owner
  ProjectId            = local.Variables.ProjectId
  Tags                 = local.common_tags
  IsProduction         = var.IsProduction
  ApplicationName      = local.Variables.ApplicationName
  Application_Settings = local.settings_map
  IsExternalApp        = true
}
```

#### After

```hcl
variable "environment" {
  type        = string
  description = "The environment in which the resources are deployed"
}

variable "environment_short_name" {
  type        = string
  description = "The short name of the environment"
  default     = ""
}

variable "tenant" {
  type = string
}

variable "deployed_by" {
  type = string
}

variable "build_number" {
  type        = string
  description = "The build number of the pipeline that deployed the resources."
}

variable "is_production" {
  type        = bool
  description = "Is this a production environment?"
}

variable "create_staging_slot" {
  type        = bool
  description = "Whether to create a secondary slot for the application"
  default     = false
}

locals {
  variables = yamldecode(file("${path.module}/vars.yml"))
  common_tags = {
    deployed_by      = var.deployed_by
    application_name = local.variables.application_name
    build_number     = var.build_number
    team_name        = local.variables.owner
  }
}

module "saif-webapp" {
  source               = "app.terraform.io/SAIFCorp/saif-webapp/azure"
  version              = ">= 3.0.0, < 4.0.0"
  environment          = var.environment
  owner                = local.variables.owner
  project_id           = local.variables.project_id
  tags                 = local.common_tags
  is_production        = var.is_production
  application_name     = local.variables.application_name
  application_settings = local.settings_map
  create_staging_slot  = var.create_staging_slot
}
```

---

## 6️⃣ Entra ID Migration

Forge 3.0 adds Entra ID authentication alongside the existing Okta configuration. The app registration is automatically created by the `saif-apiservice` module—no manual Azure Portal steps required.

> 📚 **For detailed security configuration guidance**, see the [Security Configuration Guide](../security/index.md). This section covers migration-specific steps; the security guide provides comprehensive documentation on app roles, scopes, business roles, and TypeSpec auth requirements.

### Step 1: Create API Auth Configuration

- [ ] Create `infra/api/config.yml` to define app roles and scopes for Entra ID.

- [ ] **Migrate app roles and scopes from Okta**: If you have existing permissions in your Okta configuration, migrate them to Entra ID format.

> ⚠️ **Critical**: In Entra ID, **app roles and scopes cannot share the same `value`**. Azure uses the `value` field to generate unique identifiers, and duplicates will cause deployment errors. Use the [permission naming convention](../../reference/authorization.md#permission-naming-conventions): `App.*` for app roles vs `Client.*` for scopes.

**Old Okta format (`infra/auth/ext/okta-client/user_groups.yml`)**:

```yaml
app_permissions:
  read: read
  write: write
```

**Old Okta format (`infra/auth/ext/okta-client/scopes.yml`)**:

```yaml
scopes:
  read: Allows apps to read data
  write: Allows apps to write data
```

**New Entra ID format (`infra/api/config.yml`)**:

```yaml
# App Roles (granted to users via Business Roles, or to applications for service-to-service)
app_roles:
  - value: App.Read
    display_name: Read Access
    description: Read access to the API
  - value: App.Write
    display_name: Write Access
    description: Write access to the API

# Scopes (requested by calling applications)
scopes:
  - value: Client.Read
    display_name: Read Access
    description: Read access to the API for calling applications
  - value: Client.Write
    display_name: Write Access
    description: Write access to the API for calling applications
```

- [ ] **Update Okta configuration to match**: Keep Okta and Entra ID permissions in sync by updating the Okta files to use the same values:

**Updated Okta format (`infra/auth/ext/okta-client/user_groups.yml`)**:

```yaml
app_permissions:
  App.Read: Read access to the API
  App.Write: Write access to the API
```

**Updated Okta format (`infra/auth/ext/okta-client/scopes.yml`)**:

```yaml
scopes:
  Client.Read: Read access to the API for calling applications
  Client.Write: Write access to the API for calling applications
```

> ℹ️ **Why keep them in sync?** When clients authenticate via either Okta or Entra ID, your API's authorization logic should check for the same permission values. Consistent naming across both identity providers simplifies your code and avoids authorization bugs.

### Step 2: Create API Auth Terraform

- [ ] Create `infra/api/auth.generated.tf`:

```hcl
locals {
  auth_config = yamldecode(file("${path.module}/config.yml"))

  app_roles = [
    for role in local.auth_config.app_roles : {
      value                = role.value
      allowed_member_types = ["User", "Application"]
      description          = role.description
      display_name         = role.display_name
    }
  ]

  scopes = [
    for scope in local.auth_config.scopes : {
      value                      = scope.value
      admin_consent_description  = scope.description
      admin_consent_display_name = scope.display_name
      user_consent_description   = scope.description
      user_consent_display_name  = scope.display_name
    }
  ]
}

module "application_permissions" {
  source  = "app.terraform.io/SAIFCorp/application-permissions/saif"
  version = ">= 3.0.0, < 4.0.0"

  project_id             = local.variables.project_id
  environment            = var.environment
  environment_short_name = var.environment_short_name

  app_roles = local.app_roles
  scopes    = local.scopes

  depends_on = [module.saif-appservices]
}
```

### Step 3: Create Corporate Auth Configuration

- [ ] Create `infra/auth/corp/config.yml` to define business role mappings and authorized apps:

```yaml
# Business Roles to App Role Assignments
business_roles: []

# Authorized Client Applications
authorized_apps: []
```

- [ ] **Migrate corporate business roles from Okta**: If you have existing `AuthorizedBusinessRolesCorp` entries in your old `infra/auth/user/business-role-app-role.yml` file, migrate them to the new Entra ID format:

**Old Okta format (`infra/auth/user/business-role-app-role.yml`)**:

```yaml
AuthorizedBusinessRolesCorp:
  - name: API.Readers
    appRoles:
      - read
  - name: API.Writers
    appRoles:
      - read
      - write
```

**New Entra ID format (`infra/auth/corp/config.yml`)**:

```yaml
business_roles:
  - name: API.Readers
    app_roles:
      - read
  - name: API.Writers
    app_roles:
      - read
      - write

authorized_apps: []
```

> ℹ️ **Note**: The `name` field should reference an Entra ID security group. Users in that group will be assigned the specified `app_roles` when authenticating.

- [ ] Create `infra/auth/corp/auth-internal.generated.tf`:

```hcl
terraform {
  cloud {
    organization = "SAIFCorp"
    workspaces {
      project = "your-project-id"
    }
  }
}

variable "project_id" {
  type        = string
  description = "The project ID of the current application"
}

variable "environment" {
  type        = string
  description = "The environment in which the resources are deployed"
}

variable "environment_short_name" {
  type        = string
  description = "The short name of the environment"
}

locals {
  auth_config = yamldecode(file("${path.module}/config.yml"))

  app_role_assignments = flatten([
    for role in local.auth_config.business_roles : [
      for app_role in role.app_roles : {
        role_value    = app_role
        business_role = role.name
      }
    ]
  ])

  pre_authorized_applications = [
    for app in local.auth_config.authorized_apps : {
      project_id   = app.project_id
      scope_values = app.scopes
    }
  ]
}

module "application_permissions" {
  source  = "app.terraform.io/SAIFCorp/application-permissions/saif"
  version = ">= 3.0.0, < 4.0.0"

  project_id             = var.project_id
  environment            = var.environment
  environment_short_name = var.environment_short_name

  app_role_assignments        = local.app_role_assignments
  pre_authorized_applications = local.pre_authorized_applications
}
```

### Step 4: Update External Auth (Okta) Modules

After moving the Okta auth folders to `infra/auth/ext/`, update the module versions and variable casing in each file.

> ℹ️ **What changes and what stays the same:**
>
> | Element                      | Casing                     | Example                                                         |
> | ---------------------------- | -------------------------- | --------------------------------------------------------------- |
> | Terraform variables          | `snake_case`               | `is_production`, `tenant`                                       |
> | Module input parameters      | `snake_case`               | `is_production = var.is_production`                             |
> | Local variable names         | `snake_case`               | `local.features`, `local.app_permissions`                       |
> | YAML keys in features/\*.yml | `snake_case`               | `project_id`, `front_end_project_id`                            |
> | YAML keys in user_groups.yml | `snake_case`               | `app_permissions` (was `AppPermissions`)                        |
> | YAML keys in scopes.yml      | `snake_case`               | `scopes` (was `Scopes`)                                         |
> | Module instance names        | **PascalCase** (unchanged) | `module "oktaApp-Api"`                                          |
> | Output names                 | **PascalCase** (unchanged) | `output "ClientId"`, `output "OpenIdConnectClientId"`           |
> | Module output references     | **PascalCase** (unchanged) | `module.oktaApp-Api.ClientId`, `module.oktaApp-Web[0].ClientId` |

- [ ] Update `infra/auth/ext/okta-client/okta-client.generated.tf`:

| Element Before                       | Element After                                  |
| ------------------------------------ | ---------------------------------------------- |
| `var.IsProduction`                   | `var.is_production`                            |
| `var.Tenant`                         | `var.tenant`                                   |
| `local.features.ProjectId`           | `local.features.project_id`                    |
| `local.features.FrontEndProjectId`   | `local.features.front_end_project_id`          |
| `local.features.ApplicationName`     | `local.features.application_name`              |
| `local.features.IsExperienceApi`     | `local.features.is_experience_api`             |
| `local.features.RedirectUrisNonProd` | `local.features.redirect_uris_np`              |
| `local.features.RedirectUrisProd`    | `local.features.redirect_uris_prod`            |
| `.AppPermissions`                    | `.app_permissions`                             |
| `.Scopes`                            | `.scopes` (if scopes.yml exists)               |
| `module.oktaApp-Api.ClientId`        | `module.oktaApp-Api.ClientId` ✅ (unchanged)    |
| `module.oktaApp-Web[0].ClientId`     | `module.oktaApp-Web[0].ClientId` ✅ (unchanged) |

```hcl
# Variables use snake_case
variable "is_production" {
  description = "Is this a production environment?"
  type        = bool
}

variable "tenant" {
  description = "The name of the Okta organization"
  type        = string
}

locals {
  # Local variable names use snake_case
  features        = merge([for file in fileset("${path.module}/features", "*.yml") : yamldecode(file("${path.module}/features/${file}"))]...)
  # YAML keys are now snake_case: .app_permissions (was .AppPermissions)
  app_permissions = yamldecode(file("./user_groups.yml")).app_permissions
  # YAML keys are now snake_case: .scopes (was .Scopes)
  scopes          = try(yamldecode(file("./scopes.yml")).scopes, {})
  # Feature file keys are now snake_case
  has_front_end   = try(local.features.front_end_project_id, "") != ""
}

# Module instance names remain PascalCase
module "oktaApp-Api" {
  source        = "app.terraform.io/SAIFCorp/oidc-client/okta"
  version       = ">= 3.0.0, < 4.0.0"
  # Feature file keys are snake_case: .project_id (was .ProjectId)
  project_id    = local.features.project_id
  is_production = var.is_production
  tenant        = var.tenant
  # ...
}

# Output names remain PascalCase
output "ClientId" {
  # Module output references remain PascalCase
  value = module.oktaApp-Api.ClientId
}

output "ClientSecret" {
  value     = module.oktaApp-Api.ClientSecret
  sensitive = true
}

module "oktaApp-Web" {
  count         = local.has_front_end ? 1 : 0
  source        = "app.terraform.io/SAIFCorp/oidc-client/okta"
  version       = ">= 3.0.0, < 4.0.0"
  # Feature file keys are snake_case
  project_id                = local.features.front_end_project_id
  experience_api_project_id = local.features.project_id
  application_name          = local.features.application_name
  is_production             = var.is_production
  tenant                    = var.tenant
  # Module output references remain PascalCase
  target_auth_server_id       = module.oktaApp-Api.AuthServerId
  target_auth_server_url      = module.oktaApp-Api.AuthServerUrl
  target_auth_server_audience = module.oktaApp-Api.AuthServerAudience
  # ...
}

# Output names remain PascalCase
output "OpenIdConnectClientId" {
  # Module output references remain PascalCase: .ClientId (NOT .client_id)
  value = local.has_front_end ? module.oktaApp-Web[0].ClientId : null
}

output "OpenIdConnectClientSecret" {
  value     = local.has_front_end ? module.oktaApp-Web[0].ClientSecret : null
  sensitive = true
}

output "AuthServerId" {
  value = module.oktaApp-Api.AuthServerId
}

output "AuthServerUrl" {
  value = module.oktaApp-Api.AuthServerUrl
}

output "AuthServerAudience" {
  value = module.oktaApp-Api.AuthServerAudience
}

output "TenantOrigin" {
  value = module.oktaApp-Api.TenantOrigin
}
```

- [ ] Update YAML files to use snake_case keys:

**`user_groups.yml`** - Change `AppPermissions` to `app_permissions`:

```yaml
# Before
AppPermissions:
  read: read

# After
app_permissions:
  read: read
```

> ℹ️ **Note**: The `user_groups.yml` file defines app permissions as key-value pairs where the key is the permission name and the value is its description.

**`scopes.yml`** (optional) - If present, change `Scopes` to `scopes`:

```yaml
# Before
Scopes:
  read: Read access

# After
scopes:
  read: Read access
```

> ℹ️ **Note**: The `scopes.yml` file is optional. If your project doesn't have one, you can skip this step.

**`features/*.yml`** - Change all keys to snake_case:

```yaml
# Before (api.yml)
ProjectId: it-api-exp-myapp
ApplicationName: MyApp
IsExperienceApi: true
ApiType: Experience

# After (api.yml)
project_id: it-api-exp-myapp
application_name: MyApp
is_experience_api: true
api_type: Experience
```

```yaml
# Before (web.yml) - if you have a front-end
FrontEndProjectId: it-web-myapp
RedirectUrisNonProd: []
RedirectUrisProd: []

# After (web.yml)
front_end_project_id: it-web-myapp
redirect_uris_np: []
redirect_uris_prod: []
```

- [ ] Update `infra/auth/ext/app/auth-app.generated.tf`:

| Element Before              | Element After                |
| --------------------------- | ---------------------------- |
| `local.yaml.AuthorizedApps` | `local.yaml.authorized_apps` |
| `app.ProjectId`             | `app.project_id`             |
| `app.Scopes`                | `app.scopes`                 |
| `local.features.ProjectId`  | `local.features.project_id`  |

```hcl
terraform {
  cloud {
    organization = "SAIFCorp"
    workspaces {
      project = "your-project-id"
    }
  }

  required_providers {
    okta = {
      source = "okta/okta"
    }
  }
}

provider "okta" {
  org_name       = var.okta_org_name
  base_url       = var.okta_base_url
  client_id      = var.okta_client_id
  private_key_id = var.okta_private_key_id
  private_key    = var.okta_pk
  scopes         = var.okta_scopes
}

variable "okta_pk" {
  description = "The value of the system environment variable okta_pk"
}

variable "okta_base_url" {
  description = "The base URL of the Okta organization"
}

variable "okta_client_id" {
  description = "The client ID of the Okta organization"
}

variable "okta_private_key_id" {
  description = "The private key ID of the Okta organization"
}

variable "okta_org_name" {
  description = "The name of the Okta organization"
}

variable "okta_scopes" {
  description = "The scopes of the Okta organization"
}

variable "is_production" {
  description = "Is this a production environment?"
  type        = bool
  default     = false
}

variable "tenant" {
  type        = string
  description = "The tenant name"
  default     = "ext"
  validation {
    condition     = var.tenant == "ext"
    error_message = "Tenant must be 'ext'. Corporate authentication now uses Entra ID."
  }
}

locals {
  features = merge([for file in fileset("${path.module}/features", "*.yml") : yamldecode(file("${path.module}/features/${file}"))]...)
  yaml     = yamldecode(file("./authorized-apps.yml"))
  # YAML keys are now snake_case
  authorized_apps = { for app in local.yaml.authorized_apps : app.project_id => app.scopes }
  project_ids     = toset(keys(local.authorized_apps))
  project_id      = local.features.project_id
}

module "app-auth" {
  source            = "app.terraform.io/SAIFCorp/app-auth/okta"
  version           = ">= 3.0.0, < 4.0.0"
  authorized_apps   = local.authorized_apps
  is_production     = var.is_production
  target_project_id = local.project_id
  tenant            = var.tenant
}

module "auth-server-trust" {
  source                          = "app.terraform.io/SAIFCorp/auth-server-trust/okta"
  version                         = ">= 3.0.0, < 4.0.0"
  auth_server_project_id          = local.project_id
  trusted_auth_server_project_ids = local.project_ids
}
```

- [ ] Update `authorized-apps.yml` to use snake_case keys:

```yaml
# Before
AuthorizedApps:
  - ProjectId: it-api-proc-other
    Scopes:
      - read

# After
authorized_apps:
  - project_id: it-api-proc-other
    scopes:
      - read
```

- [ ] Update `infra/auth/ext/user/auth-user.generated.tf`:

> ℹ️ **Okta Provider Configuration**: The `role-auth` module still requires an explicit Okta provider. Keep the `provider "okta"` block and related variables in this file.

| Element Before                               | Element After                          |
| -------------------------------------------- | -------------------------------------- |
| `local.features.ProjectId`                   | `local.features.project_id`            |
| `local.features.FrontEndProjectId`           | `local.features.front_end_project_id`  |
| `local.yaml.AuthorizedBusinessRolesCorp`     | _(removed - corp auth uses Entra ID)_  |
| `local.yaml.AuthorizedBusinessRolesExternal` | `local.yaml.authorized_business_roles` |
| `role.appRoles`                              | `role.app_roles`                       |

```hcl
terraform {
  cloud {
    organization = "SAIFCorp"
    workspaces {
      project = "your-project-id"
    }
  }
  required_providers {
    okta = {
      source = "okta/okta"
    }
  }
}

# Okta provider is required for role-auth module
provider "okta" {
  org_name       = var.okta_org_name
  base_url       = var.okta_base_url
  client_id      = var.okta_client_id
  private_key_id = var.okta_private_key_id
  private_key    = var.okta_pk
  scopes         = var.okta_scopes
}

variable "okta_pk" {
  description = "The value of the system environment variable okta_pk"
}

variable "okta_base_url" {
  description = "The base URL of the Okta organization"
}

variable "okta_client_id" {
  description = "The client ID of the Okta organization"
}

variable "okta_private_key_id" {
  description = "The private key ID of the Okta organization"
}

variable "okta_org_name" {
  description = "The name of the Okta organization"
}

variable "okta_scopes" {
  description = "The scopes of the Okta organization"
}

variable "is_production" {
  description = "Is this a production environment?"
  type        = bool
  default     = false
}

variable "tenant" {
  type        = string
  description = "Okta tenant, either corp or ext"
}

locals {
  yaml = yamldecode(file("./business-role-app-role.yml"))
  features = merge([for file in fileset("${path.module}/features", "*.yml") : yamldecode(file("${path.module}/features/${file}"))]...)
  # Feature keys are now snake_case
  _project_ids_with_optional_front_end = [local.features.project_id, try(local.features.front_end_project_id, "")]
  project_ids = [for id in local._project_ids_with_optional_front_end : id if id != ""]
  # Okta only handles External tenant authentication (corp uses Entra ID)
  authorized_business_roles = { for role in local.yaml.authorized_business_roles : role.name => role.app_roles }
}

module "role-auth" {
  for_each         = toset(local.project_ids)
  source           = "app.terraform.io/SAIFCorp/role-auth/okta"
  version          = ">= 3.0.0, < 4.0.0"
  project_id       = each.value
  authorized_roles = local.authorized_business_roles
  is_production    = var.is_production
}
```

- [ ] Update `business-role-app-role.yml` to use snake_case keys:

```yaml
# Before
AuthorizedBusinessRolesCorp:
  - name: My Business Role
    appRoles:
      - read

AuthorizedBusinessRolesExternal:
  - name: External Role
    appRoles:
      - read

# After (Note: Okta now only handles external users; corp auth uses Entra ID)
authorized_business_roles:
  - name: External Role
    app_roles:
      - read
```

> ℹ️ **Note**: In Forge 3.0, Okta only handles **external** user authentication. The old `AuthorizedBusinessRolesCorp` mappings should be migrated to Entra ID in `infra/auth/corp/config.yml` using the `business_roles` key. The old `AuthorizedBusinessRolesExternal` mappings become `authorized_business_roles` here for Okta external users only.
>
> If you don't have any external business role mappings yet, use an empty array:
>
> ```yaml
> authorized_business_roles: []
> ```

#### External Auth Casing Summary

| Element Type  | Casing       | Examples                                                |
| ------------- | ------------ | ------------------------------------------------------- |
| Variables     | `snake_case` | `is_production`, `tenant`                               |
| Module inputs | `snake_case` | `is_production`, `target_project_id`, `authorized_apps` |
| Module names  | PascalCase   | `oktaApp-Api`, `oktaApp-Web`                            |
| Outputs       | PascalCase   | `ClientId`, `ClientSecret`, `AuthServerId`              |

#### External Auth Module Version Summary

| Before | After |
| ------ | ----- |
| `saif-oidcclient/okta` `>=1.0.0, <2.0.0` | `oidc-client/okta` `>= 3.0.0, < 4.0.0` |
| `saif-appauth/okta` `>=1.0.0, <2.0.0` | `app-auth/okta` `>= 3.0.0, < 4.0.0` |
| `saif-authserver-trust/okta` `>=1.0.0, <2.0.0` | `auth-server-trust/okta` `>= 3.0.0, < 4.0.0` |
| `saif-roleauth/okta` `>=1.0.0, <2.0.0` | `role-auth/okta` `>= 3.0.0, < 4.0.0` |

---

## 7️⃣ Pipeline Updates

- [ ] Update the CI/CD pipeline configuration to use Forge 3.0 templates

### Before (`.azdo/azure-pipelines-api.yml`)

```yaml
resources:
  repositories:
    - repository: templates
      type: git
      name: SAIF/pipeline-templates

extends:
  template: azure-dotnet-api-v2.yml@templates
  parameters:
    # ...
    dotNetVersion: '9.x'
    # ...
```

### After (`.azdo/azure-pipelines-api.yml`)

```yaml
resources:
  repositories:
    - repository: templates
      type: git
      name: SAIF/pipeline-templates
      ref: refs/heads/releases/v3

extends:
  template: azure-dotnet-api-v3.yml@templates
  parameters:
    # ...
    dotNetVersion: '10.x'
    # ...
```

### Key Changes

| Setting         | Before                       | After                        |
| --------------- | ---------------------------- | ---------------------------- |
| Template ref    | (default/main)               | `refs/heads/releases/v3`     |
| Template        | `azure-dotnet-api-v2.yml`    | `azure-dotnet-api-v3.yml`    |
| PR Template     | `azure-dotnet-api-pr-v2.yml` | `azure-dotnet-api-pr-v3.yml` |
| `dotNetVersion` | `'9.x'`                      | `'10.x'`                     |
| `nodeVersion`   | `'20.x'`                     | `'22.x'`                     |

### Web Pipeline Updates

If your project has a React frontend, update the web pipelines as well.

#### Before (`.azdo/azure-pipelines-web.yml`)

```yaml
resources:
  repositories:
    - repository: templates
      type: git
      name: SAIF/pipeline-templates

extends:
  template: azure-react-web-v2.yml@templates
  parameters:
    # ...
    nodeVersion: '20.x'
    # ...
```

#### After (`.azdo/azure-pipelines-web.yml`)

```yaml
resources:
  repositories:
    - repository: templates
      type: git
      name: SAIF/pipeline-templates
      ref: refs/heads/releases/v3

extends:
  template: azure-react-web-v3.yml@templates
  parameters:
    # ...
    nodeVersion: '22.x'
    # ...
```

#### Web Pipeline Key Changes

| Setting       | Before                      | After                       |
| ------------- | --------------------------- | --------------------------- |
| Template ref  | (default/main)              | `refs/heads/releases/v3`    |
| Template      | `azure-react-web-v2.yml`    | `azure-react-web-v3.yml`    |
| PR Template   | `azure-react-web-pr-v2.yml` | `azure-react-web-pr-v3.yml` |
| `nodeVersion` | `'20.x'`                    | `'22.x'`                    |

### Auth Pipeline Consolidation

Forge 3.0 consolidates the separate Okta auth pipelines into a unified auth pipeline that handles both Okta and Entra ID.

#### Pipelines to Remove

- [ ] Delete the following Okta-specific pipeline files:

```powershell
# Remove old Okta auth pipelines
Remove-Item -Path .azdo/okta-appauth.yml -ErrorAction SilentlyContinue
Remove-Item -Path .azdo/okta-appauth-pr.yml -ErrorAction SilentlyContinue
Remove-Item -Path .azdo/okta-userauth.yml -ErrorAction SilentlyContinue
Remove-Item -Path .azdo/okta-userauth-pr.yml -ErrorAction SilentlyContinue
```

#### New Auth Pipelines

- [ ] Create `.azdo/azure-pipelines-auth.yml`:

```yaml
variables:
  - template: vars/base.yml
  - template: vars/auth.yml
  - template: Variables/versioning.yml@templates
  - group: TerraformCloud
  - group: Dynatrace

name: $(MajorVersion).$(MinorVersion).$(PatchVersion)$(PreReleaseVersionSeparator)$(PreReleaseVersion)

trigger: none

pool:
  vmimage: 'ubuntu-latest'

resources:
  repositories:
    - repository: templates
      type: git
      name: SAIF/pipeline-templates
      ref: refs/heads/releases/v3

extends:
  template: azure-auth.yml@templates
  parameters:
    projectId: ${{ variables.ProjectId }}
```

- [ ] Create `.azdo/azure-pipelines-auth-pr.yml`:

```yaml
variables:
  - template: vars/base.yml
  - template: vars/auth.yml
  - template: Variables/versioning.yml@templates
  - group: TerraformCloud

name: $(MajorVersion).$(MinorVersion).$(PatchVersion)$(PreReleaseVersionSeparator)$(PreReleaseVersion)

trigger: none

pool:
  vmimage: 'ubuntu-latest'

resources:
  repositories:
    - repository: templates
      type: git
      name: SAIF/pipeline-templates
      ref: refs/heads/releases/v3

extends:
  template: azure-auth-pr.yml@templates
  parameters:
    projectId: ${{ variables.ProjectId }}
```

- [ ] Create `.azdo/vars/auth.yml`:

```yaml
variables:
  - name: MajorVersion
    value: $[format('{0:yyyy}', Pipeline.StartTime)]
  - name: MinorVersion
    value: $[format('{0:M}', Pipeline.StartTime)]
  - name: PatchVersion
    value: $[counter(format('{0}.{1}.{2}', variables['MajorVersion'], variables['MinorVersion'], variables['PreReleaseVersion']), 1)]
```

---

## 8️⃣ Build and Test

After completing all changes:

- [ ] Clean and restore

```powershell
dotnet clean
dotnet restore
```

- [ ] Build

```powershell
dotnet build
```

- [ ] Run tests

```powershell
dotnet test
```

- [ ] Start AppHost locally and verify dashboard

```powershell
cd src/{AppName}.AppHost
aspire run
```

---

## 🔍 Troubleshooting

### SDK Issues

**SDK not found:**

```powershell
dotnet --list-sdks  # Verify 10.x installed
```

**Package conflicts:**

```powershell
dotnet nuget locals all --clear
dotnet restore
```

### Aspire Issues

**Aspire CLI not found:**

- See [Install Aspire](https://aspire.dev/get-started/install-cli/)

```powershell
saif doctor fix
aspire --version
```

**AppHost fails to start:**

- Verify all Aspire packages are version 13.x
- Check .NET 10 SDK is active: `dotnet --version`
- Rebuild: `dotnet clean && dotnet build`

---

## 📚 Resources

- [Security Configuration Guide](../security/index.md) - App roles, scopes, business roles, and TypeSpec auth
- [.NET 10 Release Notes](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10)
- [Aspire Documentation](https://learn.microsoft.com/en-us/dotnet/aspire/)
- [Entra ID Documentation](https://learn.microsoft.com/en-us/entra/identity/)
- [Terraform Cloud Registry](https://app.terraform.io/app/SAIFCorp/registry/modules)

---

## 🤖 Using an AI Agent for Migration

Use GitHub Copilot to automate this migration. Copy and paste the following prompt:

```text
Complete the Forge v2 to v3 migration for this repository following the migration guide.

First, use `get_doc` to retrieve the full migration guide: `guides/migration/forge-v2-to-v3`
Then copy it to `docs/migration/forge-v2-to-v3.md` in the repo so you can track progress.

Use the `#todo` tool to create a todo list with these tasks:

**Prerequisites (complete before starting migration):**
0. Baseline Build - Run `dotnet build` and `dotnet test` to verify current state builds and note any pre-existing test failures
1. Install .NET 10 SDK - Verify with `dotnet --version` (should show 10.0.x)
2. Update SAIF Tools - Run `saif doctor fix --self`, wait 20 seconds, run `saif doctor fix`, verify `aspire --version` shows 13.1.0+
3. Install dotnet-outdated-tool - Run `dotnet tool install -g dotnet-outdated-tool` (REQUIRED for package updates)

**Migration Sections (from the guide):**
4. .NET 10 Upgrade - Update TargetFramework to net10.0 in all .csproj files
5. Package Updates - Run `dotnet outdated -u` to update all packages, remove deprecated packages (Aspire.Hosting.NodeJs, OpenAPI packages), verify minimum versions per guide
6. AppHost Changes - Update SDK to `Aspire.AppHost.Sdk/13.1.0`, replace AddNpmApp with AddJavaScriptApp, remove IsAspireHost
7. Infrastructure Folder Restructuring - Rename infra/app/ to infra/api/, reorganize auth/ into ext/ and corp/ subfolders
8. Terraform Variable Casing - Convert all PascalCase variables to snake_case in .tf files and vars.yml
9. Entra ID Migration - Create infra/api/config.yml, add auth.generated.tf, create infra/auth/corp/ configuration
10. Pipeline Updates - Update to v3 pipeline templates

After completing each section:
1. Run `dotnet build` to verify the changes compile
2. Update the local migration progress file at `docs/migration/forge-v2-to-v3.md` with completed checkboxes
3. Mark the todo as complete using `#todo` before moving to the next section

**Delegation Strategy:**
Use the `#runSubagent` tool to efficiently discover files before making changes. Launch subagents in parallel for discovery tasks:

- **For .NET Upgrade (Task 4):** Find all .csproj files that reference net8.0 or net9.0
- **For Package Updates (Task 5):** Search for deprecated package references (Aspire.Hosting.NodeJs, Microsoft.AspNetCore.OpenApi)
- **For AppHost Changes (Task 6):** Locate AddNpmApp usages in AppHost resource builder files
- **For Terraform Casing (Tasks 7-8):** Find .tf and .yml files with PascalCase variables (Environment, ProjectId, etc.)

Each subagent should return the complete list of file paths that need updating for its assigned task.
```

> 💡 **Tip**: The agent will create a todo list to track progress, run terminal commands, delegate complex searches to sub-agents, and can even submit improvements to this guide based on issues encountered during your migration.

---

- 💬 [Teams Support Channel](https://teams.microsoft.com/l/channel/19%3Acb611810fb0b42b080cfff5590bdd51c%40thread.tacv2/Support?groupId=514d2dac-2d62-48ce-bf99-0fa0ce39469c&tenantId=a86cb8ed-369b-4df5-ace5-43811f6e08cf)
