From 8f76e8159ebc13428e2519f396b837fca108eb85 Mon Sep 17 00:00:00 2001 From: fboucher Date: Mon, 16 Feb 2026 14:22:11 -0500 Subject: [PATCH 01/14] feat: Add Keycloak authentication and authorization - Integrated Keycloak via Aspire.Hosting.Keycloak package - Added OpenID Connect authentication to BlazorApp with Keycloak provider - Configured home page as public, all other pages require authentication - Added Login/Logout UI components in top-right corner - Configured id_token_hint for proper logout flow - Added comprehensive Keycloak setup documentation - Updated .gitignore to exclude Development settings and local config files This implements private website access control where only selected users can authenticate through Keycloak. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 10 +- Directory.Packages.props | 98 +++++++------ README.md | 113 ++++++++------- docs/KEYCLOAK_AUTH.md | 0 .../NoteBookmark.AppHost.csproj | 31 ++-- .../Components/Layout/MainLayout.razor | 94 +++++++------ .../Components/Pages/Home.razor | 132 +++++++++--------- .../Components/Pages/Login.razor | 27 ++++ .../Components/Pages/Logout.razor | 22 +++ .../Components/Shared/LoginDisplay.razor | 36 +++++ src/NoteBookmark.BlazorApp/Program.cs | 74 ++++++++++ 11 files changed, 405 insertions(+), 232 deletions(-) create mode 100644 docs/KEYCLOAK_AUTH.md create mode 100644 src/NoteBookmark.BlazorApp/Components/Pages/Login.razor create mode 100644 src/NoteBookmark.BlazorApp/Components/Pages/Logout.razor create mode 100644 src/NoteBookmark.BlazorApp/Components/Shared/LoginDisplay.razor diff --git a/.gitignore b/.gitignore index c9650be..8bf56b6 100644 --- a/.gitignore +++ b/.gitignore @@ -490,6 +490,7 @@ NoteBookmark.Api/obj/ NoteBookmark.Api/appsettings.Development.json NoteBookmark.BlazorApp/appsettings.Development.json +src/NoteBookmark.BlazorApp/appsettings.Development.json .azure NoteBookmark.AppHost/appsettings.Development.json @@ -502,4 +503,11 @@ src/NoteBookmark.AppHost/appsettings.json todos/ # AI Team folder -.ai-team/ \ No newline at end of file +.ai-team/ +.ai-team-templates/ + +# Copilot config +.copilot/ + +# Squad/Agent files +.github/agents/ \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index ebf1998..815c680 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,51 +1,47 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index cd413dd..15862fa 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,59 @@ -# Note Bookmark - -![GitHub Release](https://img.shields.io/github/v/release/fboucher/NoteBookmark) ![.NET](https://img.shields.io/badge/9.0-512BD4?logo=dotnet&logoColor=fff) [![Unit Tests](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml/badge.svg)](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml) [![Reka AI](https://img.shields.io/badge/Power%20By-2E2F2F?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NjYuOTQgNjgxLjI2Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiBub25lOwogICAgICB9CgogICAgICAuY2xzLTIgewogICAgICAgIGZpbGw6ICNmMWVlZTc7CiAgICAgIH0KICAgIDwvc3R5bGU%2BCiAgPC9kZWZzPgogIDxyZWN0IGNsYXNzPSJjbHMtMSIgeD0iLS4yOSIgeT0iLS4xOSIgd2lkdGg9IjY2Ny4yMiIgaGVpZ2h0PSI2ODEuMzMiLz4KICA8Zz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxOC4zNCwwTDgyLjY3LjE2QzM2Ljg1LjE5LS4yOSwzNy4zOC0uMjksODMuMjd2MjM1LjEyaDc0LjkzVjcxLjc1aDI0My43M1YwaC0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik03Mi45NywzNjIuOTdIMHYzMTguMTZoNzIuOTd2LTMxOC4xNloiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxNS4zMywzNjIuODRoLTk5LjEzbC0xMDkuNSwxMDcuMjljLTEzLjk1LDEzLjY4LTIxLjgyLDMyLjM3LTIxLjgyLDUxLjkyczcuODYsMzguMjQsMjEuODIsNTEuOTJsMTA5LjUsMTA3LjI5aDEwMS42M2wtMTYyLjQ1LTE2MS43MiwxNTkuOTUtMTU2LjY3di0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0zNDguNTksODIuOTJ2MTUyLjIzYzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMjMwLjI4di03MS43OGgtMjQwLjMzVjg1Ljg3YzAtNy43NCw2LjI4LTE0LjA2LDE0LjA1LTE0LjA2aDE0NC4zMmM3Ljc0LDAsMTQuMDUsNi4yOCwxNC4wNSwxNC4wNiwwLDUuOS0zLjcxLDExLjE3LTkuMjMsMTMuMmwtMTQ3LjQ1LDU2LjIzdjcwLjczbDE3NC41Ny02Mi4yYzMzLjA0LTExLjgsNTUuMTEtNDMuMTMsNTUuMTEtNzguMjZ2LTIuNjdDNjY3LDM3LDYyOS44LS4xOSw1ODMuOTUtLjE5aC0xNTIuMjdjLTQ1Ljg5LDAtODMuMDUsMzcuMTktODMuMDUsODMuMTFoLS4wM1oiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTY2Ni45NCw1OTguMTJ2LTE1Mi4yM2MwLTQ1Ljg5LTM3LjE2LTgzLjExLTgzLjA1LTgzLjExaC0yMzAuMjh2NzEuNzhoMjQwLjMzdjE2MC42MWMwLDcuNzQtNi4yOCwxNC4wNi0xNC4wNSwxNC4wNmgtMTQ0LjMxYy03Ljc0LDAtMTQuMDUtNi4yOC0xNC4wNS0xNC4wNiwwLTUuOSwzLjcxLTExLjE3LDkuMjMtMTMuMmwxNDcuNDUtNTYuMjN2LTcwLjczbC0xNzQuNTcsNjIuMmMtMzMuMDQsMTEuOC01NS4xMSw0My4xMy01NS4xMSw3OC4yNnYyLjY3YzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMTUyLjI3YzQ1Ljg5LDAsODMuMDUtMzcuMTksODMuMDUtODMuMTFoLjAzWiIvPgogIDwvZz4KPC9zdmc%2B&logoSize=auto&labelColor=2E2F2F&color=F1EEE7)](https://reka.ai/) - - - - -I use this project mostly everyday. I build it to help me collecting my thoughts about articles, and blob posts I read during the week and then aggregate them in a #ReadingNotes blog post. You can find those post on my blog [here](https://frankysnotes.com). - -NoteBookmark is composed of three main sections: - -- **Post**: where you can manage a posts "to read", and add notes to them. -- **Generate Summary**: where you can generate a summary of the posts you read. -- **Summaries**: where you can see all the summaries you generated. - -![Slide show of all NoteBookmark Screens](gh/images/NoteBookmark-Tour_hd.gif) - -## How to deploy Your own NoteBookmark - -### Get the code on your machine - -- Fork this repository to your account. -- Clone the repository to your local machine. - - -### Deploy the solution (5 mins) - -Using Azure Developer CLI let's initialize your environment. In a terminal, at the root of the project, run the following command. When ask give it a name (ex: NoteBookmark-dev). - -```bash -azd init -``` - -Now let's deploy the solution. Run the following command in the terminal. You will have to select your Azure subscription where you want to deploy the solution, and a location (ex: eastus). - -```bash -azd up -``` - -It should take around five minutes to deploy the solution. Once it's done, you will see the URL for **Deploying service blazor-app**. - -### Secure the App in a few clicks - -The app is now deployed, but it's not secure. Navigate to the Azure Portal, and find the Resource Group you just deployed (ex: rg-notebookmark-dev). In this resource group, open the Container App **Container App**. From the left menu, select **Authentication** and click the **Add identity provider**. - -You can choose between multiple providers, I like to use Microsoft since it's deploy in Azure and I'm already logged in. If Microsoft is choose, select the recomended **Client secret expiration** (ex: 180 days). You can keep all the other default settings. Click **Add**. - -Next time you will navigate to the app, you will be prompt a to login with your Microsoft account. The first time you will have a **Permissions requested** screen, click **Accept**. - -Voila! Your app is now secure. - -## Contributing - -Your contributions are welcome! Take a look at [CONTRIBUTING](/CONTRIBUTING.md) for details. +# Note Bookmark + +![GitHub Release](https://img.shields.io/github/v/release/fboucher/NoteBookmark) ![.NET](https://img.shields.io/badge/9.0-512BD4?logo=dotnet&logoColor=fff) [![Unit Tests](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml/badge.svg)](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml) [![Reka AI](https://img.shields.io/badge/Power%20By-2E2F2F?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NjYuOTQgNjgxLjI2Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiBub25lOwogICAgICB9CgogICAgICAuY2xzLTIgewogICAgICAgIGZpbGw6ICNmMWVlZTc7CiAgICAgIH0KICAgIDwvc3R5bGU%2BCiAgPC9kZWZzPgogIDxyZWN0IGNsYXNzPSJjbHMtMSIgeD0iLS4yOSIgeT0iLS4xOSIgd2lkdGg9IjY2Ny4yMiIgaGVpZ2h0PSI2ODEuMzMiLz4KICA8Zz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxOC4zNCwwTDgyLjY3LjE2QzM2Ljg1LjE5LS4yOSwzNy4zOC0uMjksODMuMjd2MjM1LjEyaDc0LjkzVjcxLjc1aDI0My43M1YwaC0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik03Mi45NywzNjIuOTdIMHYzMTguMTZoNzIuOTd2LTMxOC4xNloiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxNS4zMywzNjIuODRoLTk5LjEzbC0xMDkuNSwxMDcuMjljLTEzLjk1LDEzLjY4LTIxLjgyLDMyLjM3LTIxLjgyLDUxLjkyczcuODYsMzguMjQsMjEuODIsNTEuOTJsMTA5LjUsMTA3LjI5aDEwMS42M2wtMTYyLjQ1LTE2MS43MiwxNTkuOTUtMTU2LjY3di0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0zNDguNTksODIuOTJ2MTUyLjIzYzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMjMwLjI4di03MS43OGgtMjQwLjMzVjg1Ljg3YzAtNy43NCw2LjI4LTE0LjA2LDE0LjA1LTE0LjA2aDE0NC4zMmM3Ljc0LDAsMTQuMDUsNi4yOCwxNC4wNSwxNC4wNiwwLDUuOS0zLjcxLDExLjE3LTkuMjMsMTMuMmwtMTQ3LjQ1LDU2LjIzdjcwLjczbDE3NC41Ny02Mi4yYzMzLjA0LTExLjgsNTUuMTEtNDMuMTMsNTUuMTEtNzguMjZ2LTIuNjdDNjY3LDM3LDYyOS44LS4xOSw1ODMuOTUtLjE5aC0xNTIuMjdjLTQ1Ljg5LDAtODMuMDUsMzcuMTktODMuMDUsODMuMTFoLS4wM1oiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTY2Ni45NCw1OTguMTJ2LTE1Mi4yM2MwLTQ1Ljg5LTM3LjE2LTgzLjExLTgzLjA1LTgzLjExaC0yMzAuMjh2NzEuNzhoMjQwLjMzdjE2MC42MWMwLDcuNzQtNi4yOCwxNC4wNi0xNC4wNSwxNC4wNmgtMTQ0LjMxYy03Ljc0LDAtMTQuMDUtNi4yOC0xNC4wNS0xNC4wNiwwLTUuOSwzLjcxLTExLjE3LDkuMjMtMTMuMmwxNDcuNDUtNTYuMjN2LTcwLjczbC0xNzQuNTcsNjIuMmMtMzMuMDQsMTEuOC01NS4xMSw0My4xMy01NS4xMSw3OC4yNnYyLjY3YzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMTUyLjI3YzQ1Ljg5LDAsODMuMDUtMzcuMTksODMuMDUtODMuMTFoLjAzWiIvPgogIDwvZz4KPC9zdmc%2B&logoSize=auto&labelColor=2E2F2F&color=F1EEE7)](https://reka.ai/) + + + + +I use this project mostly everyday. I build it to help me collecting my thoughts about articles, and blob posts I read during the week and then aggregate them in a #ReadingNotes blog post. You can find those post on my blog [here](https://frankysnotes.com). + +NoteBookmark is composed of three main sections: + +- **Post**: where you can manage a posts "to read", and add notes to them. +- **Generate Summary**: where you can generate a summary of the posts you read. +- **Summaries**: where you can see all the summaries you generated. + +![Slide show of all NoteBookmark Screens](gh/images/NoteBookmark-Tour_hd.gif) + +## How to deploy Your own NoteBookmark + +### Get the code on your machine + +- Fork this repository to your account. +- Clone the repository to your local machine. + + +### Deploy the solution (5 mins) + +Using Azure Developer CLI let's initialize your environment. In a terminal, at the root of the project, run the following command. When ask give it a name (ex: NoteBookmark-dev). + +```bash +azd init +``` + +Now let's deploy the solution. Run the following command in the terminal. You will have to select your Azure subscription where you want to deploy the solution, and a location (ex: eastus). + +```bash +azd up +``` + +It should take around five minutes to deploy the solution. Once it's done, you will see the URL for **Deploying service blazor-app**. + +### Secure the App in a few clicks + +The app is now deployed, but it's not secure. Navigate to the Azure Portal, and find the Resource Group you just deployed (ex: rg-notebookmark-dev). In this resource group, open the Container App **Container App**. From the left menu, select **Authentication** and click the **Add identity provider**. + +You can choose between multiple providers, I like to use Microsoft since it's deploy in Azure and I'm already logged in. If Microsoft is choose, select the recomended **Client secret expiration** (ex: 180 days). You can keep all the other default settings. Click **Add**. + +Next time you will navigate to the app, you will be prompt a to login with your Microsoft account. The first time you will have a **Permissions requested** screen, click **Accept**. + +Voila! Your app is now secure. + +## Documentation + +For detailed setup guides and configuration information: +- [Keycloak Authentication Setup](/docs/KEYCLOAK_AUTH.md) - Complete guide for setting up Keycloak authentication + +## Contributing + +Your contributions are welcome! Take a look at [CONTRIBUTING](/CONTRIBUTING.md) for details. diff --git a/docs/KEYCLOAK_AUTH.md b/docs/KEYCLOAK_AUTH.md new file mode 100644 index 0000000..e69de29 diff --git a/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj b/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj index 2267fb5..678c04d 100644 --- a/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj +++ b/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj @@ -1,17 +1,16 @@ - - - - Exe - true - 0784f0a9-b1e6-4e65-8d31-00f1369f6d75 - - - - - - - - - - + + + Exe + true + 0784f0a9-b1e6-4e65-8d31-00f1369f6d75 + + + + + + + + + + \ No newline at end of file diff --git a/src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor b/src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor index 340eae8..097843c 100644 --- a/src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor +++ b/src/NoteBookmark.BlazorApp/Components/Layout/MainLayout.razor @@ -1,45 +1,49 @@ -@inherits LayoutComponentBase -@using Microsoft.FluentUI.AspNetCore.Components -@using Microsoft.FluentUI.AspNetCore.Components.Extensions -@inject NavigationManager NavigationManager - - - - Note Bookmark - - - - - - - - -
- @Body -
-
-
- - Documentation and demos - - About Blazor - -
- - - - - -
- An unhandled error has occurred. - Reload - 🗙 -
- - - -@code { - - public DesignThemeModes Mode { get; set; } - public OfficeColor? OfficeColor { get; set; } -} +@inherits LayoutComponentBase +@using Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@using NoteBookmark.BlazorApp.Components.Shared +@inject NavigationManager NavigationManager + + + + Note Bookmark + + + + + + + + + + + +
+ @Body +
+
+
+ + Documentation and demos + + About Blazor + +
+ + + + + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + +@code { + + public DesignThemeModes Mode { get; set; } + public OfficeColor? OfficeColor { get; set; } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Home.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Home.razor index 6f7e2e6..c00d62b 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Home.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Home.razor @@ -1,66 +1,68 @@ -@page "/" -@using Microsoft.FluentUI.AspNetCore.Components -@inject NavigationManager Navigation - -Home - NoteBookmark - - -

📚 NoteBookmark

- - -

Your personal reading companion for capturing thoughts and insights from articles and blog posts. - Transform your reading notes into polished summaries, perfect for sharing your weekly discoveries.

-
- - - - -
📝
-

Manage Posts

-

Collect articles to read and add your notes as you go through them.

-
-
- - - -
🔍
-

AI-Powered Search

-

Discover relevant content with intelligent suggestions tailored to your interests.

-
-
- - - -
-

Generate Summaries

-

Create beautiful summaries of your reading notes with AI assistance.

-
-
-
- - - - -

Built with Modern Tech

- - - .NET 9 - - - Blazor - - - Fluent UI Blazor - - - Aspire - - - Azure Table Storage - - - Reka AI - - -
+@page "/" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.FluentUI.AspNetCore.Components +@inject NavigationManager Navigation + +Home - NoteBookmark + + +

📚 NoteBookmark

+ + +

Your personal reading companion for capturing thoughts and insights from articles and blog posts. + Transform your reading notes into polished summaries, perfect for sharing your weekly discoveries.

+
+ + + + +
📝
+

Manage Posts

+

Collect articles to read and add your notes as you go through them.

+
+
+ + + +
🔍
+

AI-Powered Search

+

Discover relevant content with intelligent suggestions tailored to your interests.

+
+
+ + + +
+

Generate Summaries

+

Create beautiful summaries of your reading notes with AI assistance.

+
+
+
+ + + + +

Built with Modern Tech

+ + + .NET 9 + + + Blazor + + + Fluent UI Blazor + + + Aspire + + + Azure Table Storage + + + Reka AI + + +
\ No newline at end of file diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Login.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Login.razor new file mode 100644 index 0000000..bb64738 --- /dev/null +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Login.razor @@ -0,0 +1,27 @@ +@page "/login" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Authentication.OpenIdConnect +@inject NavigationManager Navigation +@inject IHttpContextAccessor HttpContextAccessor +@code { + protected override async Task OnInitializedAsync() + { + // Get the return URL from query string or default to home + var uri = new Uri(Navigation.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var returnUrl = query["returnUrl"] ?? "/"; + + // Trigger authentication challenge via HttpContext + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext != null) + { + var authProperties = new AuthenticationProperties + { + RedirectUri = returnUrl + }; + await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); + } + } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Logout.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Logout.razor new file mode 100644 index 0000000..4bfd9da --- /dev/null +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Logout.razor @@ -0,0 +1,22 @@ +@page "/logout" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Authentication.Cookies +@using Microsoft.AspNetCore.Authentication.OpenIdConnect +@inject IHttpContextAccessor HttpContextAccessor +@code { + protected override async Task OnInitializedAsync() + { + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext != null) + { + var properties = new AuthenticationProperties + { + RedirectUri = "/" + }; + await httpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, properties); + await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } + } +} diff --git a/src/NoteBookmark.BlazorApp/Components/Shared/LoginDisplay.razor b/src/NoteBookmark.BlazorApp/Components/Shared/LoginDisplay.razor new file mode 100644 index 0000000..d3b4595 --- /dev/null +++ b/src/NoteBookmark.BlazorApp/Components/Shared/LoginDisplay.razor @@ -0,0 +1,36 @@ +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Components.Authorization +@inject NavigationManager Navigation + + + + + Hello, @context.User.Identity?.Name + + Logout + + + + + + Login + + + + +@code { + private void Login() + { + var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri); + if (string.IsNullOrEmpty(returnUrl)) + { + returnUrl = "/"; + } + Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false); + } + + private void Logout() + { + Navigation.NavigateTo("/logout", forceLoad: false); + } +} diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index bdee7e0..4c902ab 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -1,3 +1,6 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.FluentUI.AspNetCore.Components; using NoteBookmark.AIServices; using NoteBookmark.BlazorApp; @@ -57,6 +60,54 @@ }); +// Add authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; +}) +.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) +.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => +{ + options.Authority = builder.Configuration["Keycloak:Authority"]; + options.ClientId = builder.Configuration["Keycloak:ClientId"]; + options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"]; + options.ResponseType = "code"; + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + + options.TokenValidationParameters = new() + { + NameClaimType = "preferred_username", + RoleClaimType = "roles" + }; + + // Configure logout to properly pass id_token_hint to Keycloak + options.Events = new OpenIdConnectEvents + { + OnRedirectToIdentityProviderForSignOut = context => + { + // Get the id_token from saved tokens + var idToken = context.HttpContext.GetTokenAsync("id_token").Result; + if (!string.IsNullOrEmpty(idToken)) + { + context.ProtocolMessage.IdTokenHint = idToken; + } + return Task.CompletedTask; + } + }; +}); + +builder.Services.AddAuthorization(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddHttpContextAccessor(); + // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -78,7 +129,30 @@ app.UseStaticFiles(); app.UseAntiforgery(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapRazorComponents() .AddInteractiveServerRenderMode(); +// Authentication endpoints +app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => +{ + var authProperties = new AuthenticationProperties + { + RedirectUri = returnUrl ?? "/" + }; + await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); +}); + +app.MapGet("/authentication/logout", async (HttpContext context) => +{ + var authProperties = new AuthenticationProperties + { + RedirectUri = "/" + }; + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); +}); + app.Run(); From 41b06703077618b7857354e9f92801747a146344 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 16 Feb 2026 14:48:19 -0500 Subject: [PATCH 02/14] feat: Implements Keycloak authentication Adds Keycloak authentication to the application, securing all pages except the home page. This enhances security by requiring users to log in via Keycloak to access most of the application's features. --- docs/KEYCLOAK_AUTH.md | 82 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/docs/KEYCLOAK_AUTH.md b/docs/KEYCLOAK_AUTH.md index e69de29..f62248c 100644 --- a/docs/KEYCLOAK_AUTH.md +++ b/docs/KEYCLOAK_AUTH.md @@ -0,0 +1,82 @@ +# Keycloak Authentication Setup + +## Overview + +NoteBookmark now requires authentication via Keycloak (or any OpenID Connect provider). Only the home page is accessible without authentication - all other pages require a logged-in user. + +## Configuration + +### 1. Keycloak Server Setup + +You'll need a Keycloak server running. For local development: + +```bash +docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:latest start-dev +``` + +### 2. Create a Realm + +1. Log into Keycloak admin console (http://localhost:8080) +2. Create a new realm called "notebookmark" + +### 3. Create a Client + +1. In the realm, create a new client: + - Client ID: `notebookmark` + - Client Protocol: `openid-connect` + - Access Type: `confidential` + - Valid Redirect URIs: `https://localhost:5001/*` (adjust for your environment) + - Web Origins: `https://localhost:5001` (adjust for your environment) + +2. Get the client secret from the Credentials tab + +### 4. Configure the Application + +Update `appsettings.json` or environment variables: + +```json +{ + "Keycloak": { + "Authority": "http://localhost:8080/realms/notebookmark", + "ClientId": "notebookmark", + "ClientSecret": "your-client-secret-here" + } +} +``` + +**Environment Variables (recommended for production):** + +```bash +export Keycloak__Authority="https://your-keycloak-server/realms/notebookmark" +export Keycloak__ClientId="notebookmark" +export Keycloak__ClientSecret="your-secret" +``` + +### 5. Add Users + +In Keycloak, create users in the realm who should have access to your private website. + +## How It Works + +- **Home page (/)**: Public - no authentication required +- **All other pages**: Protected with `[Authorize]` attribute +- **Login/Logout**: UI in the header shows login button when not authenticated +- **Session**: Uses cookie-based authentication with OpenID Connect + +## Technical Details + +- Uses `Microsoft.AspNetCore.Authentication.OpenIdConnect` package +- Cookie-based session management +- Authorization state cascaded throughout the component tree +- `AuthorizeRouteView` in Routes.razor handles route-level protection + +## Files Modified + +- `Program.cs`: Added authentication middleware and configuration +- `Routes.razor`: Changed to `AuthorizeRouteView` for authorization support +- `MainLayout.razor`: Added `LoginDisplay` component to header +- `_Imports.razor`: Added authorization namespaces +- All pages except `Home.razor`: Added `@attribute [Authorize]` +- `Components/Shared/LoginDisplay.razor`: New component for login/logout UI +- `Components/Pages/Login.razor`: Login page +- `Components/Pages/Logout.razor`: Logout page From 766a902acc9c08a1542c6787a159ea189b60c388 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 16 Feb 2026 15:06:36 -0500 Subject: [PATCH 03/14] feat: Enables Keycloak authentication Adds initial support for Keycloak authentication to the Blazor app. This includes adding necessary packages and configuring the application to use OpenID Connect for authentication. Additionally, sets up squad related file tracking. --- .gitattributes | 5 +++++ .gitignore | 4 +++- src/NoteBookmark.AppHost/AppHost.cs | 1 + src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c030ef7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Squad: union merge for append-only team state files +.ai-team/decisions.md merge=union +.ai-team/agents/*/history.md merge=union +.ai-team/log/** merge=union +.ai-team/orchestration-log/** merge=union diff --git a/.gitignore b/.gitignore index 8bf56b6..e56eef0 100644 --- a/.gitignore +++ b/.gitignore @@ -510,4 +510,6 @@ todos/ .copilot/ # Squad/Agent files -.github/agents/ \ No newline at end of file +.github/agents/ +# Squad (local AI team - not committed) +.ai-team/ diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index d9460d7..d9aa72c 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -1,3 +1,4 @@ +using Aspire.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Projects; diff --git a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj index 7cf93ce..bc0fb7c 100644 --- a/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj +++ b/src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj @@ -4,6 +4,7 @@ + From b5551cbe47af7477e4aeed18c1295bb9b0281d9d Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 16 Feb 2026 15:30:33 -0500 Subject: [PATCH 04/14] feat: Adds Keycloak authentication to BlazorApp Introduces Keycloak for user authentication, enhancing security with OpenID Connect. Adds Keycloak as an Aspire resource for simplified management and data persistence. Includes documentation for Keycloak setup and configuration, aiding developers in configuring authentication. Adds authorization attributes to Blazor pages, restricting access to authenticated users. --- Directory.Packages.props | 1 + docker-compose/docker-compose.yaml | 26 ++++ docs/KEYCLOAK_SETUP.md | 126 ++++++++++++++++++ src/NoteBookmark.AppHost/AppHost.cs | 6 + .../NoteBookmark.AppHost.csproj | 1 + .../Components/Pages/Error.razor | 2 + .../Components/Pages/PostEditor.razor | 3 +- .../Components/Pages/PostEditorLight.razor | 3 +- .../Components/Pages/Posts.razor | 2 + .../Components/Pages/Search.razor | 2 + .../Components/Pages/Settings.razor | 3 +- .../Components/Pages/Summaries.razor | 2 + .../Components/Pages/SummaryEditor.razor | 3 +- .../Components/Routes.razor | 48 ++++++- 14 files changed, 217 insertions(+), 11 deletions(-) create mode 100644 docs/KEYCLOAK_SETUP.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 815c680..0e0bb5f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,7 @@ + diff --git a/docker-compose/docker-compose.yaml b/docker-compose/docker-compose.yaml index 01fb91d..4df4523 100644 --- a/docker-compose/docker-compose.yaml +++ b/docker-compose/docker-compose.yaml @@ -1,6 +1,23 @@ name: note-bookmark services: + keycloak: + image: "quay.io/keycloak/keycloak:26.1" + container_name: "notebookmark-keycloak" + command: ["start-dev"] + environment: + KEYCLOAK_ADMIN: "admin" + KEYCLOAK_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD:-admin}" + KC_HTTP_PORT: "8080" + KC_HOSTNAME_STRICT: "false" + KC_HOSTNAME_STRICT_HTTPS: "false" + KC_HTTP_ENABLED: "true" + ports: + - "8080:8080" + volumes: + - keycloak-data:/opt/keycloak/data + networks: + - "aspire" api: image: "fboucher/notebookmark-api:latest" container_name: "notebookmark-api" @@ -27,14 +44,23 @@ services: ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true" HTTP_PORTS: "8004" services__api__http__0: "http://api:8000" + services__keycloak__http__0: "http://keycloak:8080" + Keycloak__Authority: "${KEYCLOAK_AUTHORITY:-http://localhost:8080/realms/notebookmark}" + Keycloak__ClientId: "${KEYCLOAK_CLIENT_ID:-notebookmark}" + Keycloak__ClientSecret: "${KEYCLOAK_CLIENT_SECRET}" ports: - "8005:8004" - "8007:8006" depends_on: api: condition: "service_started" + keycloak: + condition: "service_started" networks: - "aspire" +volumes: + keycloak-data: + driver: "local" networks: aspire: driver: "bridge" diff --git a/docs/KEYCLOAK_SETUP.md b/docs/KEYCLOAK_SETUP.md new file mode 100644 index 0000000..7780d53 --- /dev/null +++ b/docs/KEYCLOAK_SETUP.md @@ -0,0 +1,126 @@ +# Keycloak Authentication Setup + +## Overview + +NoteBookmark uses Keycloak for authentication via OpenID Connect. This provides enterprise-grade identity management with support for single sign-on, user federation, and fine-grained access control. + +## Architecture + +- **AppHost**: Manages Keycloak as an Aspire resource with data persistence +- **Keycloak Container**: Runs on port 8080 with development mode enabled +- **BlazorApp**: Configured for OpenID Connect authentication pointing to Keycloak realm + +## Local Development + +### Default Credentials + +- **Admin Console**: http://localhost:8080/admin +- **Username**: `admin` +- **Password**: `admin` (or set via `KEYCLOAK_ADMIN_PASSWORD` environment variable) + +### Realm Configuration + +The application expects a realm named `notebookmark` with: +- **Client ID**: `notebookmark` +- **Client Secret**: Set via `KEYCLOAK_CLIENT_SECRET` environment variable +- **Valid Redirect URIs**: + - `https://localhost:*/signin-oidc` + - `http://localhost:*/signin-oidc` +- **Valid Post Logout Redirect URIs**: + - `https://localhost:*` + - `http://localhost:*` + +### Environment Variables + +For development, set these in `appsettings.development.json`: + +```json +{ + "Keycloak": { + "Authority": "http://localhost:8080/realms/notebookmark", + "ClientId": "notebookmark", + "ClientSecret": "YOUR_CLIENT_SECRET" + } +} +``` + +## Docker Compose + +Keycloak is defined in `docker-compose/docker-compose.yaml`: + +- **Image**: `quay.io/keycloak/keycloak:26.1` +- **Port**: 8080 +- **Data Volume**: `keycloak-data` for persistence +- **Network**: `aspire` (shared with API and BlazorApp) + +### Environment Variables for docker-compose + +Set these environment variables before running docker-compose: + +```bash +export KEYCLOAK_ADMIN_PASSWORD=your_secure_password +export KEYCLOAK_CLIENT_SECRET=your_client_secret +export KEYCLOAK_AUTHORITY=http://localhost:8080/realms/notebookmark +export KEYCLOAK_CLIENT_ID=notebookmark +``` + +## Production Considerations + +### HTTPS Requirements + +In production, you **must**: +1. Set `Keycloak:Authority` to an HTTPS URL (e.g., `https://keycloak.yourdomain.com/realms/notebookmark`) +2. Use valid SSL certificates for Keycloak +3. Ensure `RequireHttpsMetadata = true` in OpenID Connect configuration (default) + +### Secrets Management + +Never commit secrets to source control. Use: +- Azure Key Vault for production secrets +- User Secrets for local development: `dotnet user-secrets set "Keycloak:ClientSecret" "your-secret"` +- Environment variables in deployment environments + +### Keycloak Configuration + +For production: +1. Disable development mode (`start-dev` → `start`) +2. Configure proper database backend (PostgreSQL recommended) +3. Enable clustering if needed for high availability +4. Set up proper logging and monitoring +5. Configure rate limiting and security headers + +## First-Time Setup + +1. **Start Keycloak**: Run the AppHost or `docker-compose up keycloak` +2. **Access Admin Console**: Navigate to http://localhost:8080/admin +3. **Login**: Use admin/admin +4. **Create Realm**: + - Name it `notebookmark` + - Configure as needed +5. **Create Client**: + - Client ID: `notebookmark` + - Client Protocol: `openid-connect` + - Access Type: `confidential` + - Valid Redirect URIs: `https://localhost:*/signin-oidc` + - Copy the client secret from Credentials tab +6. **Update Configuration**: Add client secret to `appsettings.development.json` +7. **Create Users**: Add users in Users section of realm + +## Troubleshooting + +### "Unable to connect to Keycloak" +- Ensure Keycloak container is running: `docker ps | grep keycloak` +- Check port 8080 is not already in use +- Verify network connectivity: `curl http://localhost:8080` + +### "Invalid redirect URI" +- Check Keycloak client configuration matches your app's redirect URI +- Ensure wildcards are properly configured for development + +### "Invalid client secret" +- Verify `Keycloak:ClientSecret` matches the value in Keycloak admin console +- Check environment variables are properly set + +### "HTTPS metadata required" +- For development: Set `RequireHttpsMetadata = false` in Program.cs (already configured) +- For production: Use HTTPS Authority URL diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index d9aa72c..d2cb411 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -8,6 +8,10 @@ #pragma warning disable ASPIRECOMPUTE001 var compose = builder.AddDockerComposeEnvironment("docker-env"); +// Add Keycloak authentication server +var keycloak = builder.AddKeycloak("keycloak", port: 8080) + .WithDataVolume(); // Persist Keycloak data across container restarts + var noteStorage = builder.AddAzureStorage("nb-storage"); var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); @@ -30,7 +34,9 @@ builder.AddProject("blazor-app") .WithReference(api) .WithReference(tables) // Server-side access to Azure Tables for unmasked settings + .WithReference(keycloak) // Reference Keycloak for authentication .WaitFor(api) + .WaitFor(keycloak) .WithExternalHttpEndpoints() .WithEnvironment("REKA_API_KEY", apiKey) .WithComputeEnvironment(compose); // comment this line to deploy to Azure diff --git a/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj b/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj index 678c04d..3e6cdf8 100644 --- a/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj +++ b/src/NoteBookmark.AppHost/NoteBookmark.AppHost.csproj @@ -5,6 +5,7 @@ 0784f0a9-b1e6-4e65-8d31-00f1369f6d75 + diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Error.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Error.razor index 576cc2d..cd14c6e 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Error.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Error.razor @@ -1,4 +1,6 @@ @page "/Error" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization @using System.Diagnostics Error diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/PostEditor.razor b/src/NoteBookmark.BlazorApp/Components/Pages/PostEditor.razor index 0167b46..d2d5f52 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/PostEditor.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/PostEditor.razor @@ -1,5 +1,6 @@ @page "/posteditor/{id?}" - +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.BlazorApp @using NoteBookmark.Domain @inject PostNoteClient client diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/PostEditorLight.razor b/src/NoteBookmark.BlazorApp/Components/Pages/PostEditorLight.razor index 8448b02..2f7a149 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/PostEditorLight.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/PostEditorLight.razor @@ -1,5 +1,6 @@ @page "/posteditorlight/{id?}" - +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.BlazorApp @using NoteBookmark.BlazorApp.Components.Layout @using NoteBookmark.Domain diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Posts.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Posts.razor index 1d2672d..fdfef51 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Posts.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Posts.razor @@ -1,4 +1,6 @@ @page "/posts" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.BlazorApp.Components.Shared @using NoteBookmark.Domain @using Microsoft.FluentUI.AspNetCore.Components diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Search.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Search.razor index 9c4b02d..4eab507 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Search.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Search.razor @@ -1,4 +1,6 @@ @page "/search" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.AIServices @using NoteBookmark.BlazorApp.Components.Shared @using NoteBookmark.Domain diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor index 4b3a16d..bc250c9 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor @@ -1,5 +1,6 @@ @page "/settings" - +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using Microsoft.FluentUI.AspNetCore.Components.Extensions @using NoteBookmark.Domain @inject ILogger Logger diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/Summaries.razor b/src/NoteBookmark.BlazorApp/Components/Pages/Summaries.razor index c9dc2fb..9009a4f 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/Summaries.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/Summaries.razor @@ -1,4 +1,6 @@ @page "/summaries" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using NoteBookmark.Domain @inject PostNoteClient client @rendermode InteractiveServer diff --git a/src/NoteBookmark.BlazorApp/Components/Pages/SummaryEditor.razor b/src/NoteBookmark.BlazorApp/Components/Pages/SummaryEditor.razor index c9bfd49..4397287 100644 --- a/src/NoteBookmark.BlazorApp/Components/Pages/SummaryEditor.razor +++ b/src/NoteBookmark.BlazorApp/Components/Pages/SummaryEditor.razor @@ -1,5 +1,6 @@ @page "/summaryeditor/{number?}" - +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization @using Markdig @using NoteBookmark.Domain @using NoteBookmark.AIServices diff --git a/src/NoteBookmark.BlazorApp/Components/Routes.razor b/src/NoteBookmark.BlazorApp/Components/Routes.razor index 4d3379c..842b358 100644 --- a/src/NoteBookmark.BlazorApp/Components/Routes.razor +++ b/src/NoteBookmark.BlazorApp/Components/Routes.razor @@ -1,8 +1,42 @@ - +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Authorization - - - - - - + + + + + + + + @if (context.User.Identity?.IsAuthenticated != true) + { + + +

Authentication Required

+

You need to be logged in to access this page.

+ + Login + +
+ } + else + { + + +

Access Denied

+

You don't have permission to access this page.

+ + Go to Home + +
+ } +
+
+ +
+
+
+ +@code { + [Inject] private NavigationManager NavigationManager { get; set; } = default!; +} From 57b14776c5ea63523ae557ebaea14b661845300a Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 16 Feb 2026 15:47:07 -0500 Subject: [PATCH 05/14] Fix(keycloak): Handles asynchronous sign out redirect Ensures proper handling of asynchronous operations during sign-out redirect to Keycloak. This change avoids potential deadlocks by awaiting the result of getting the id_token from the HttpContext. --- src/NoteBookmark.BlazorApp/Program.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 4c902ab..97170f9 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -91,15 +91,14 @@ // Configure logout to properly pass id_token_hint to Keycloak options.Events = new OpenIdConnectEvents { - OnRedirectToIdentityProviderForSignOut = context => + OnRedirectToIdentityProviderForSignOut = async context => { // Get the id_token from saved tokens - var idToken = context.HttpContext.GetTokenAsync("id_token").Result; + var idToken = await context.HttpContext.GetTokenAsync("id_token"); if (!string.IsNullOrEmpty(idToken)) { context.ProtocolMessage.IdTokenHint = idToken; } - return Task.CompletedTask; } }; }); From 65e2f6ec2b6b60024cb5313247ff5b8f29fb39cf Mon Sep 17 00:00:00 2001 From: fboucher Date: Mon, 16 Feb 2026 16:09:09 -0500 Subject: [PATCH 06/14] docs(ai-team): Docker-compose deployment documentation Session: 2026-02-16-docker-compose-docs Requested by: fboucher Changes: - logged session to .ai-team/log/2026-02-16-docker-compose-docs.md - merged 2 decision(s) from inbox into decisions.md - consolidated Keycloak decisions with dual-mode architecture, logout flow, and orchestration details - propagated updates to 2 agent history file(s) (Hicks, Newt) - deleted merged inbox files --- .ai-team/agents/bishop/charter.md | 20 + .ai-team/agents/bishop/history.md | 24 + .ai-team/agents/bishop/history_new.md | 18 + .ai-team/agents/hicks/charter.md | 19 + .ai-team/agents/hicks/history.md | 162 ++++++ .ai-team/agents/hudson/charter.md | 19 + .ai-team/agents/hudson/history.md | 46 ++ .ai-team/agents/newt/charter.md | 19 + .ai-team/agents/newt/history.md | 121 +++++ .ai-team/agents/ripley/charter.md | 18 + .ai-team/agents/ripley/history.md | 83 ++++ .ai-team/agents/scribe/charter.md | 20 + .ai-team/agents/scribe/history.md | 11 + .ai-team/casting/history.json | 22 + .ai-team/casting/policy.json | 40 ++ .ai-team/casting/registry.json | 46 ++ .ai-team/ceremonies.md | 41 ++ .ai-team/decisions.md | 86 ++++ .ai-team/log/2026-02-14-ai-agent-migration.md | 42 ++ .../log/2026-02-14-bishop-review-date-fix.md | 20 + .../log/2026-02-16-docker-compose-docs.md | 19 + .ai-team/log/2026-02-16-keycloak-auth.md | 21 + .ai-team/log/2026-02-16-scribe-session.md | 38 ++ .ai-team/routing.md | 11 + .../aspire-keycloak-integration/SKILL.md | 463 ++++++++++++++++++ .../aspire-third-party-integration/SKILL.md | 140 ++++++ .../skills/blazor-interactive-events/SKILL.md | 117 +++++ .../blazor-oidc-authentication/SKILL.md | 187 +++++++ .../skills/blazor-oidc-redirects/SKILL.md | 178 +++++++ .../skills/resilient-ai-json-parsing/SKILL.md | 164 +++++++ .../resilient-json-deserialization/SKILL.md | 32 ++ .ai-team/skills/squad-conventions/SKILL.md | 69 +++ .ai-team/team.md | 21 + 33 files changed, 2337 insertions(+) create mode 100644 .ai-team/agents/bishop/charter.md create mode 100644 .ai-team/agents/bishop/history.md create mode 100644 .ai-team/agents/bishop/history_new.md create mode 100644 .ai-team/agents/hicks/charter.md create mode 100644 .ai-team/agents/hicks/history.md create mode 100644 .ai-team/agents/hudson/charter.md create mode 100644 .ai-team/agents/hudson/history.md create mode 100644 .ai-team/agents/newt/charter.md create mode 100644 .ai-team/agents/newt/history.md create mode 100644 .ai-team/agents/ripley/charter.md create mode 100644 .ai-team/agents/ripley/history.md create mode 100644 .ai-team/agents/scribe/charter.md create mode 100644 .ai-team/agents/scribe/history.md create mode 100644 .ai-team/casting/history.json create mode 100644 .ai-team/casting/policy.json create mode 100644 .ai-team/casting/registry.json create mode 100644 .ai-team/ceremonies.md create mode 100644 .ai-team/decisions.md create mode 100644 .ai-team/log/2026-02-14-ai-agent-migration.md create mode 100644 .ai-team/log/2026-02-14-bishop-review-date-fix.md create mode 100644 .ai-team/log/2026-02-16-docker-compose-docs.md create mode 100644 .ai-team/log/2026-02-16-keycloak-auth.md create mode 100644 .ai-team/log/2026-02-16-scribe-session.md create mode 100644 .ai-team/routing.md create mode 100644 .ai-team/skills/aspire-keycloak-integration/SKILL.md create mode 100644 .ai-team/skills/aspire-third-party-integration/SKILL.md create mode 100644 .ai-team/skills/blazor-interactive-events/SKILL.md create mode 100644 .ai-team/skills/blazor-oidc-authentication/SKILL.md create mode 100644 .ai-team/skills/blazor-oidc-redirects/SKILL.md create mode 100644 .ai-team/skills/resilient-ai-json-parsing/SKILL.md create mode 100644 .ai-team/skills/resilient-json-deserialization/SKILL.md create mode 100644 .ai-team/skills/squad-conventions/SKILL.md create mode 100644 .ai-team/team.md diff --git a/.ai-team/agents/bishop/charter.md b/.ai-team/agents/bishop/charter.md new file mode 100644 index 0000000..5912552 --- /dev/null +++ b/.ai-team/agents/bishop/charter.md @@ -0,0 +1,20 @@ +# Bishop — Code Reviewer + +## Role +Code reviewer and quality gatekeeper. You analyze code changes for correctness, security, maintainability, and risk. + +## Responsibilities +- Code review with focus on PROS, CONS, risks, and security +- Identify potential bugs, edge cases, and architectural concerns +- Evaluate code quality, readability, and maintainability +- Flag security vulnerabilities and performance issues +- Provide actionable, easy-to-understand feedback + +## Boundaries +- You review and provide feedback — you don't rewrite code +- Focus on substantive issues — not style nitpicks +- Approve or reject with clear rationale +- When rejecting, recommend who should handle the revision + +## Model +**Preferred:** auto (per-task) diff --git a/.ai-team/agents/bishop/history.md b/.ai-team/agents/bishop/history.md new file mode 100644 index 0000000..958a52f --- /dev/null +++ b/.ai-team/agents/bishop/history.md @@ -0,0 +1,24 @@ +# Bishop's History + +## Project Learnings (from import) + +**Project:** NoteBookmark +**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework +**Owner:** fboucher (fboucher@outlook.com) + +This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. + +## Learnings + +### Learnings +- **Architecture**: The application uses a split configuration model where some settings are in Azure Table Storage (user-editable) and some are in `IConfiguration` (static/environment). +- **Risk**: The migration to Microsoft.Agents.AI introduced user-configurable AI settings, but the implementation in `ResearchService` and `SummaryService` does not consume these user-provided settings, relying instead on static configuration. +- **Pattern**: Services are injected as Transient in Blazor Server, but rely on singleton-like `IConfiguration`. +- **Anti-Pattern**: The Blazor Server app consumes the public API for its own settings. Because the API correctly masks secrets for the browser, the Server app also receives masked secrets, breaking the configuration wiring. Trusted server-side components need a privileged path to access secrets. +- **Solution (2026-02-14)**: Resolved the configuration wiring issue by introducing `AISettingsProvider`, a server-side component that reads unmasked secrets directly from Azure Table Storage. This maintains security (public API still masks keys) while allowing internal services to function correctly. This confirms the "Split Configuration Model" where trusted components use direct data access and untrusted clients use the restricted API. + +### Learnings +- **Architecture & Patterns**: We are adopting custom `JsonConverter` implementations to handle "hallucinated" or inconsistent data formats from AI services. The pattern is: `Try strict parse -> Try heuristic parse -> Fallback to raw string -> Fallback to null (safe fail)`. +- **Defensive Parsing**: For AI-generated JSON, we explicitly handle `Number`, `Boolean`, and complex token types (`StartArray`, `StartObject`) even if the schema defines a field as `string`. +- **Timestamp Heuristics**: We distinguish between Unix seconds and milliseconds using `int.MaxValue` (Year 2038 threshold) as the pivot point. +- **User Preferences**: Frank prioritizes application stability over data strictness; prefers keeping raw data if parsing fails rather than throwing exceptions. diff --git a/.ai-team/agents/bishop/history_new.md b/.ai-team/agents/bishop/history_new.md new file mode 100644 index 0000000..0fc088b --- /dev/null +++ b/.ai-team/agents/bishop/history_new.md @@ -0,0 +1,18 @@ +# Bishop's History + +## Project Learnings (from import) + +**Project:** NoteBookmark +**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework +**Owner:** fboucher (fboucher@outlook.com) + +This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. + +## Learnings + +### Learnings +- **Architecture**: The application uses a split configuration model where some settings are in Azure Table Storage (user-editable) and some are in `IConfiguration` (static/environment). +- **Risk**: The migration to Microsoft.Agents.AI introduced user-configurable AI settings, but the implementation in `ResearchService` and `SummaryService` does not consume these user-provided settings, relying instead on static configuration. +- **Pattern**: Services are injected as Transient in Blazor Server, but rely on singleton-like `IConfiguration`. +- **Anti-Pattern**: The Blazor Server app consumes the public API for its own settings. Because the API correctly masks secrets for the browser, the Server app also receives masked secrets, breaking the configuration wiring. Trusted server-side components need a privileged path to access secrets. +- **Solution (2026-02-14)**: Resolved the configuration wiring issue by introducing `AISettingsProvider`, a server-side component that reads unmasked secrets directly from Azure Table Storage. This maintains security (public API still masks keys) while allowing internal services to function correctly. This confirms the "Split Configuration Model" where trusted components use direct data access and untrusted clients use the restricted API. diff --git a/.ai-team/agents/hicks/charter.md b/.ai-team/agents/hicks/charter.md new file mode 100644 index 0000000..9d50c3d --- /dev/null +++ b/.ai-team/agents/hicks/charter.md @@ -0,0 +1,19 @@ +# Hicks — Backend Developer + +## Role +Backend specialist focusing on .NET services, APIs, AI integration, and server-side logic. + +## Responsibilities +- AI services implementation and migration +- .NET Core APIs and services +- Dependency injection and configuration +- Database and data access layers +- Integration with external services + +## Boundaries +- You own backend code — don't modify Blazor UI components +- Focus on functionality and correctness — let the tester validate edge cases +- Consult Ripley on architectural changes + +## Model +**Preferred:** claude-sonnet-4.5 (writing code) diff --git a/.ai-team/agents/hicks/history.md b/.ai-team/agents/hicks/history.md new file mode 100644 index 0000000..ca690e5 --- /dev/null +++ b/.ai-team/agents/hicks/history.md @@ -0,0 +1,162 @@ +# Hicks' History + +## Project Learnings (from import) + +**Project:** NoteBookmark +**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework +**Owner:** fboucher (fboucher@outlook.com) + +This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. + +## Learnings + +### AI Services Migration to Microsoft.Agents.AI +- **File locations:** + - `src/NoteBookmark.AIServices/ResearchService.cs` - Web research with structured output + - `src/NoteBookmark.AIServices/SummaryService.cs` - Text summarization + - `Directory.Packages.props` - Central Package Management configuration + +- **Architecture patterns:** + - Use `ChatClientAgent` from Microsoft.Agents.AI as provider-agnostic wrapper + - Create `IChatClient` using OpenAI client with custom endpoint for compatibility + - Structured output via `AIJsonUtilities.CreateJsonSchema()` and `ChatResponseFormat.ForJsonSchema()` + - Configuration fallback: Settings.AiApiKey → REKA_API_KEY env var + +- **Configuration strategy:** + - Settings model already had AI configuration fields (AiApiKey, AiBaseUrl, AiModelName) + - Backward compatible with REKA_API_KEY environment variable + - Default values preserve Reka compatibility (reka-flash-3.1, reka-flash-research) + +- **DI registration:** + - Removed HttpClient dependency from AI services + - Changed from `AddHttpClient()` to `AddTransient()` in Program.cs + - Services now manage their own HTTP connections via OpenAI client + +- **Package management:** + - Project uses Central Package Management (CPM) + - Package versions go in `Directory.Packages.props`, not .csproj files + - Removed Reka.SDK dependency completely + - Added: Microsoft.Agents.AI (1.0.0-preview.260209.1), Microsoft.Extensions.AI.OpenAI (10.1.1-preview.1.25612.2) + +📌 **Team Update (2026-02-14):** Migration to Microsoft AI Agent Framework consolidated and finalized. Decision merged from Ripley (plan), Newt (settings), Hudson (tests), and Hicks (implementation) — decided by Ripley, Newt, Hudson, Hicks + +### JSON Deserialization Resilience +- **File locations:** + - `src/NoteBookmark.Domain/PostSuggestion.cs` - Domain model with custom JSON converters + - `src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs` - Tests for date handling resilience + +- **Pattern for handling variable AI output:** + - AI providers can return date fields in different formats (DateTime objects, Unix timestamps, ISO strings, booleans, arrays) + - Use custom `JsonConverter` to handle multiple input formats and normalize to consistent string format + - Gracefully degrade on parse failures - return null instead of throwing exceptions + - Skip unexpected complex types (objects, arrays) rather than failing + +- **DateOnlyJsonConverter implementation:** + - Handles `JsonTokenType.String` - parses any date string format and normalizes to "yyyy-MM-dd", or keeps original if not parseable + - Handles `JsonTokenType.Number` - converts Unix timestamps (both seconds and milliseconds) + - Handles `JsonTokenType.True/False` - converts boolean to string representation + - Handles `JsonTokenType.StartObject/StartArray` - skips complex types and returns null + - All parsing failures wrapped in try-catch with reader.Skip() to prevent deserialization exceptions + - Property type remains `string?` for maximum flexibility + - Comprehensive test coverage for all edge cases (booleans, numbers, objects, arrays, invalid strings) + +### Aspire Keycloak Integration for Authentication +- **File locations:** + - `src/NoteBookmark.AppHost/AppHost.cs` - Aspire AppHost with Keycloak resource + - `Directory.Packages.props` - Central package management with Keycloak hosting package + +- **Architecture pattern:** + - Use `AddKeycloak()` extension method to add Keycloak container resource to AppHost + - Keycloak runs in Docker container using `quay.io/keycloak/keycloak` image + - Default admin credentials: username=admin, password generated and stored in user secrets + - Data persistence via `WithDataVolume()` to survive container restarts + +- **Configuration:** + - Keycloak resource exposed on port 8080 (default Keycloak port) + - Both API and Blazor app reference Keycloak resource via `WithReference(keycloak)` + - WaitFor dependencies ensure Keycloak starts before dependent services + - For private website security, user management done in Keycloak admin console (create realm, configure users) + +- **Package versions:** + - Added `Aspire.Hosting.Keycloak` version `13.1.0-preview.1.25616.3` (preview version, stable 13.0.2 not yet available) + - Package follows Aspire's Central Package Management (CPM) pattern + +- **Next steps for authentication:** + - Client integration: Add `Aspire.Keycloak.Authentication` to API and Blazor projects + - Configure JWT Bearer authentication for API with `AddKeycloakJwtBearer()` + - Configure OpenId Connect authentication for Blazor with `AddKeycloakOpenIdConnect()` + - Create realm in Keycloak admin console and configure client applications + - Add user management to restrict access to selected users only + +📌 **Team Update (2026-02-16):** Keycloak Authentication Architecture consolidated. Ripley designed architecture, Hicks added Keycloak resource to AppHost, Newt implemented OpenID Connect in BlazorApp — decided by Ripley, Hicks, Newt + +### Keycloak Infrastructure Implementation (2026-02-16) + +- **AppHost Configuration:** + - Added Keycloak resource via `AddKeycloak("keycloak", port: 8080)` with data volume persistence + - BlazorApp references Keycloak via `WithReference(keycloak)` and waits for startup with `WaitFor(keycloak)` + - Service discovery automatically provides connection string to BlazorApp + +- **Docker Compose Setup:** + - Keycloak container: `quay.io/keycloak/keycloak:26.1` with `start-dev` command + - Port mapping: 8080:8080 for HTTP access in development + - Named volume `keycloak-data` persists realms, users, and configuration + - Environment variables: `KEYCLOAK_ADMIN`, `KEYCLOAK_ADMIN_PASSWORD`, HTTP-specific settings + - Network: Shares `aspire` bridge network with API and BlazorApp containers + - BlazorApp depends on both API and Keycloak services + +- **Configuration Flow:** + - AppHost Keycloak reference → Service discovery → BlazorApp environment (`services__keycloak__http__0`) + - BlazorApp reads Keycloak config from: `Keycloak:Authority`, `Keycloak:ClientId`, `Keycloak:ClientSecret` + - Docker compose supports overrides via environment variables with defaults (`${VAR:-default}`) + +- **Package Dependencies:** + - Added `Aspire.Hosting.AppHost` version 13.1.1 to Directory.Packages.props (was missing, caused build errors) + - `Aspire.Hosting.Keycloak` already present at version 13.1.1-preview.1.26105.8 + +- **Documentation:** + - Created `/docs/KEYCLOAK_SETUP.md` with setup instructions, configuration, and troubleshooting + - Covers development vs production considerations, HTTPS requirements, secrets management + +### Keycloak Logout Flow Fix (2026-02-16) + +- **Issue:** + - Keycloak logout error "Missing parameters: id_token_hint" + - OnRedirectToIdentityProviderForSignOut handler used blocking `.Result` call + - Blocking async in Blazor Server context prevented proper token retrieval + +- **Solution:** + - Changed lambda from synchronous to async: `OnRedirectToIdentityProviderForSignOut = async context =>` + - Changed token retrieval from blocking `.Result` to proper await: `var idToken = await context.HttpContext.GetTokenAsync("id_token");` + - Removed unnecessary `return Task.CompletedTask` (implicit with async lambda) + +- **Pattern for OpenID Connect event handlers:** + - Always use async lambdas when accessing async APIs like `GetTokenAsync()` + - Never use `.Result` in Blazor Server - it can cause deadlocks and context issues + - Token retrieval from HttpContext must be awaited properly in async pipeline + +### Keycloak Dual-Mode Architecture (2026-02-16) + +- **Problem:** + - Port conflict: `AddDockerComposeEnvironment()` loaded docker-compose.yaml with Keycloak on port 8080, AND `AddKeycloak()` tried to create Keycloak on same port + - Development needed Aspire-managed Keycloak, production needed standalone docker-compose orchestration + +- **Solution:** + - Removed `AddDockerComposeEnvironment()` and `.WithComputeEnvironment(compose)` calls entirely + - Split AppHost.cs into two conditional branches: `if (builder.Environment.IsDevelopment())` vs `else` + - Development: Aspire manages Keycloak via `AddKeycloak()`, runs storage emulator, full service discovery + - Production: No Keycloak reference in AppHost, docker-compose.yaml manages all containers independently + +- **Architecture pattern:** + - Development mode: AppHost orchestrates all resources (Keycloak, Storage Emulator, API, BlazorApp) + - Production mode: AppHost only defines resource references for Azure deployment, docker-compose runs actual containers + - Keycloak configured via environment variables in docker-compose for production (Authority, ClientId, ClientSecret) + - docker-compose.yaml remains unchanged - production-ready with persistent volumes and proper networking + +- **File changes:** + - `src/NoteBookmark.AppHost/AppHost.cs`: Split into dev/prod branches, removed docker-compose reference + - `docs/KEYCLOAK_SETUP.md`: Updated architecture section to explain dual-mode approach + - Build verified: Solution compiles with no errors + + +📌 Team update (2026-02-16): Keycloak Authentication & Orchestration decisions consolidated—dual-mode dev/prod architecture now in single decision block covering authentication, authorization, orchestration, and logout flow. — decided by Ripley, Hicks, Newt diff --git a/.ai-team/agents/hudson/charter.md b/.ai-team/agents/hudson/charter.md new file mode 100644 index 0000000..2b39b2a --- /dev/null +++ b/.ai-team/agents/hudson/charter.md @@ -0,0 +1,19 @@ +# Hudson — Tester + +## Role +Quality assurance specialist. You write tests, verify edge cases, and ensure code correctness. + +## Responsibilities +- Unit tests and integration tests +- Test coverage analysis +- Edge case validation +- Test maintenance and refactoring +- Quality gate enforcement + +## Boundaries +- You write tests — you don't fix the code under test (report bugs to implementers) +- Focus on behavior verification, not implementation details +- Flag gaps, but let implementers decide how to fix + +## Model +**Preferred:** claude-sonnet-4.5 (writing test code) diff --git a/.ai-team/agents/hudson/history.md b/.ai-team/agents/hudson/history.md new file mode 100644 index 0000000..e43fdf2 --- /dev/null +++ b/.ai-team/agents/hudson/history.md @@ -0,0 +1,46 @@ +# Hudson's History + +## Project Learnings (from import) + +**Project:** NoteBookmark +**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework +**Owner:** fboucher (fboucher@outlook.com) + +This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. + +## Learnings + +### Test Project Structure +- Test projects follow Central Package Management pattern (Directory.Packages.props) +- PackageReference items must not include Version attributes when CPM is enabled +- PackageVersion items in Directory.Packages.props define the versions +- Test projects use xUnit with FluentAssertions and Moq as the testing stack + +### AI Services Testing Strategy +- **File:** `src/NoteBookmark.AIServices.Tests/` - Unit test project for AI services +- **ResearchService tests:** 14 tests covering configuration, error handling, structured output +- **SummaryService tests:** 17 tests covering configuration, error handling, text generation +- Both services share identical configuration pattern: GetSettings() method with fallback hierarchy +- Configuration priority: `AppSettings:AiApiKey` → `AppSettings:REKA_API_KEY` → `REKA_API_KEY` env var +- Default baseUrl: "https://api.reka.ai/v1" +- Default models: "reka-flash-research" (Research), "reka-flash-3.1" (Summary) +- Services catch all exceptions and return safe defaults (empty PostSuggestions or empty string) +- Tests use mocked IConfiguration and ILogger - no actual API calls + +### Package Dependencies Added +- `Microsoft.Extensions.Configuration` (10.0.1) - Required for test mocks +- `Microsoft.Extensions.Logging.Abstractions` (10.0.2) - Required by Microsoft.Agents.AI dependency + +📌 **Team Update (2026-02-14):** Migration to Microsoft AI Agent Framework consolidated and finalized. Decision merged from Ripley (plan), Newt (settings), Hudson (tests), and Hicks (implementation) — decided by Ripley, Newt, Hudson, Hicks + +### Security Architecture for Settings +- **Challenge:** API endpoint masks secrets for security, but server-side Blazor app was calling that endpoint and receiving masked values, causing AI services to fail +- **Solution:** Server-side settings provider with direct Azure Table Storage access +- **File:** `src/NoteBookmark.BlazorApp/AISettingsProvider.cs` - Server-side only, bypasses HTTP API +- **Pattern:** Direct TableServiceClient access to Settings table, returns unmasked values for AI services +- **Security boundary:** API GetSettings endpoint still masks for HTTP responses; server-side DI gets unmasked values +- **Configuration:** BlazorApp now has Azure Table Storage reference in AppHost (like API project) +- **Package added to BlazorApp:** `Aspire.Azure.Data.Tables`, `Azure.Data.Tables` +- Settings provider follows same fallback hierarchy as API: Database → IConfiguration → Environment variables +- All existing tests pass (184 total: 153 API + 31 AI Services) +- Build succeeds with only pre-existing warnings (no new issues introduced) diff --git a/.ai-team/agents/newt/charter.md b/.ai-team/agents/newt/charter.md new file mode 100644 index 0000000..58bb529 --- /dev/null +++ b/.ai-team/agents/newt/charter.md @@ -0,0 +1,19 @@ +# Newt — Frontend Developer + +## Role +Frontend specialist focusing on Blazor UI, components, pages, and user experience. + +## Responsibilities +- Blazor components and pages +- UI/UX implementation +- Form handling and validation +- Client-side state management +- Styling and responsiveness + +## Boundaries +- You own frontend code — don't modify backend services +- Focus on user-facing features — backend logic stays in services +- Coordinate with Hicks on API contracts + +## Model +**Preferred:** claude-sonnet-4.5 (writing code) diff --git a/.ai-team/agents/newt/history.md b/.ai-team/agents/newt/history.md new file mode 100644 index 0000000..a70d34a --- /dev/null +++ b/.ai-team/agents/newt/history.md @@ -0,0 +1,121 @@ +# Newt's History + +## Project Learnings (from import) + +**Project:** NoteBookmark +**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework +**Owner:** fboucher (fboucher@outlook.com) + +This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. + +## Learnings + +### Settings Page Structure +- **Location:** `src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor` +- Uses FluentUI components (FluentTextField, FluentTextArea, FluentStack, etc.) +- Bound to `Domain.Settings` model via EditForm with two-way binding +- Settings are loaded via `PostNoteClient.GetSettings()` and saved via `PostNoteClient.SaveSettings()` +- Uses InteractiveServer render mode +- Follows pattern: FluentStack containers with width="100%" for form field organization + +### Domain Model Pattern +- **Location:** `src/NoteBookmark.Domain/Settings.cs` +- Implements `ITableEntity` for Azure Table Storage +- Properties decorated with `[DataMember(Name="snake_case_name")]` for serialization +- Uses nullable string properties for all user-configurable fields +- Special validation attributes like `[ContainsPlaceholder("content")]` for prompt fields + +### AI Provider Configuration Fields +- Added three new properties to Settings model: + - `AiApiKey`: Password field for sensitive API key storage + - `AiBaseUrl`: URL field for AI provider endpoint + - `AiModelName`: Text field for model identifier +- UI uses `TextFieldType.Password` for API key security +- Added visual separation with FluentDivider and section heading +- Included helpful placeholder examples in URL and model name fields + +### Keycloak/OIDC Authentication Pattern +- **Package:** `Microsoft.AspNetCore.Authentication.OpenIdConnect` (v10.0.3) +- **Configuration Location:** `appsettings.json` under `Keycloak` section (Authority, ClientId, ClientSecret) +- **Middleware Order:** Authentication → Authorization middleware must be between UseAntiforgery and MapRazorComponents +- **Authorization Setup:** + - Add `AddAuthentication()` with Cookie + OpenIdConnect schemes + - Add `AddAuthorization()` and `AddCascadingAuthenticationState()` to services + - Use `AuthorizeRouteView` instead of `RouteView` in Routes.razor + - Wrap Router in `` component +- **Page Protection:** Use `@attribute [Authorize]` on protected pages (all except Home.razor) +- **Public Pages:** Use `@attribute [AllowAnonymous]` on public pages (Home.razor, Login.razor, Logout.razor) +- **Login/Logout Flow:** + - Login: `/authentication/login` endpoint calls `ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme)` + - Logout: `/authentication/logout` endpoint signs out from both Cookie and OpenIdConnect schemes + - Login/Logout pages redirect to these endpoints with `forceLoad: true` + - **Critical:** Login page must extract returnUrl from query string and pass relative path to auth endpoint + - **Critical:** LoginDisplay must use `Navigation.ToBaseRelativePath()` to get current page as returnUrl +- **UI Pattern:** + - `LoginDisplay.razor` component uses `` to show user name + logout or login button + - Place in header layout for global visibility + - Wrap LoginDisplay and other header actions in `FluentStack` with `HorizontalGap` for proper spacing + - FluentUI icons: `Icons.Regular.Size16.Person()` for login, `Icons.Regular.Size16.ArrowExit()` for logout +- **Claims Configuration:** + - NameClaimType: "preferred_username" (Keycloak standard) + - RoleClaimType: "roles" + - Scopes: openid, profile, email + +### Blazor Interactive Components Event Handling +- **Critical:** Components with event handlers (OnClick, OnChange, etc.) require `@rendermode InteractiveServer` directive +- Without rendermode directive, click handlers and other events silently fail (no errors, just unresponsive) +- LoginDisplay component needed `@rendermode InteractiveServer` to handle Login/Logout button clicks +- Place rendermode directive at the top of the component file, before other directives +- Login.razor and Logout.razor don't need rendermode because they only execute OnInitialized lifecycle method (no user interaction) + +### Blazor Server Authentication Challenge Pattern +- **Critical:** NavigationManager.NavigateTo() with forceLoad: true during OnInitialized() causes NavigationException in Blazor Server with interactive render modes +- **Solution:** Use HttpContext.ChallengeAsync() directly instead of navigation redirect +- **Pattern:** Inject IHttpContextAccessor, extract HttpContext, call ChallengeAsync with OpenIdConnectDefaults.AuthenticationScheme +- **Required:** Add `builder.Services.AddHttpContextAccessor()` to Program.cs +- **Login.razor Pattern:** + - Use OnInitializedAsync() (async) instead of OnInitialized() (sync) + - Extract returnUrl from query string + - Create AuthenticationProperties with RedirectUri set to returnUrl + - Call httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties) +- This triggers server-side authentication flow without client-side navigation errors + +### Header Layout Positioning +- FluentHeader with FluentSpacer pushes content to the right +- Use inline `Style="margin-right: 8px;"` on FluentStack to add padding from edge of header +- Maintain HorizontalGap between adjacent items (LoginDisplay and settings icon) +- VerticalAlignment="VerticalAlignment.Center" keeps header items vertically aligned + +📌 **Team Update (2026-02-14):** Migration to Microsoft AI Agent Framework consolidated and finalized. Decision merged from Ripley (plan), Newt (settings), Hudson (tests), and Hicks (implementation) — decided by Ripley, Newt, Hudson, Hicks + +📌 **Team Update (2026-02-16):** Keycloak Authentication Architecture consolidated. Ripley designed architecture, Hicks added Keycloak resource to AppHost, Newt implemented OpenID Connect in BlazorApp — decided by Ripley, Hicks, Newt + +### Authorization Route Protection Pattern +- **Routes.razor:** Use `AuthorizeRouteView` instead of `RouteView` to enable route-level authorization +- **Cascading State:** Wrap Router in `` component +- **Page Protection:** Add `@attribute [Authorize]` to pages requiring authentication +- **Public Pages:** Add `@attribute [AllowAnonymous]` to public pages (Home, Login, Logout, Error) +- **Not Authorized UI:** AuthorizeRouteView's `` template provides custom UI for unauthorized access + - Show "Authentication Required" with Login button for unauthenticated users + - Show "Access Denied" with Home button for authenticated but unauthorized users + - Use FluentIcon for visual feedback (LockClosed for auth required, ShieldError for access denied) +- **Protected Pages:** Posts, Settings, Summaries, PostEditor, PostEditorLight, Search, SummaryEditor all require authentication +- **Public Pages:** Home (landing page), Login, Logout, Error remain accessible without authentication + +### Docker Compose Deployment Documentation +- **Location:** `/docs/docker-compose-deployment.md` +- Dual deployment strategy documented: + 1. Generate from Aspire: `dotnet run --project src/NoteBookmark.AppHost --publisher manifest --output-path ./docker-compose` + 2. Use checked-in docker-compose.yaml for quick start without repo clone +- Environment variables configured via `.env` file (never committed to git) +- `.env-sample` file provides template with placeholders for: + - Azure Storage connection strings (Table and Blob endpoints) + - Keycloak admin password + - Keycloak client credentials (authority, client ID, client secret) +- AppHost maintains `AddDockerComposeEnvironment("docker-env")` for integration +- Docker Compose file uses service dependency with `depends_on` for proper startup order +- Keycloak data persists in named volume `keycloak-data` +- README.md updated with link to docker-compose deployment documentation + + +📌 Team update (2026-02-16): Keycloak Authentication & Orchestration decisions consolidated—dual-mode dev/prod architecture now in single decision block covering authentication, authorization, orchestration, and logout flow. — decided by Ripley, Hicks, Newt diff --git a/.ai-team/agents/ripley/charter.md b/.ai-team/agents/ripley/charter.md new file mode 100644 index 0000000..301d283 --- /dev/null +++ b/.ai-team/agents/ripley/charter.md @@ -0,0 +1,18 @@ +# Ripley — Lead + +## Role +Lead developer and architect. You make final calls on design, coordinate the team, and review critical work. + +## Responsibilities +- Architecture decisions and design patterns +- Code review and quality gates +- Team coordination and task decomposition +- Risk assessment and technical strategy + +## Boundaries +- You review, but don't implement everything yourself — delegate to specialists +- Balance speed with quality — push back on shortcuts that create debt +- Escalate to the user when decisions need product/business input + +## Model +**Preferred:** auto (task-aware selection) diff --git a/.ai-team/agents/ripley/history.md b/.ai-team/agents/ripley/history.md new file mode 100644 index 0000000..3682447 --- /dev/null +++ b/.ai-team/agents/ripley/history.md @@ -0,0 +1,83 @@ +# Ripley's History + +## Project Learnings (from import) + +**Project:** NoteBookmark +**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework +**Owner:** fboucher (fboucher@outlook.com) + +This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. + +## Learnings + +### AI Services Architecture +- **Current implementation:** Uses Microsoft AI Agent Framework with provider-agnostic abstraction +- **Two services:** ResearchService (web search + structured output) and SummaryService (simple chat) +- **Configuration pattern:** Services use `Func>` provider pattern + - Primary source: User-saved settings from Azure Table Storage via API + - Fallback: IConfiguration (environment variables, appsettings.json) + - BlazorApp fetches settings via PostNoteClient.GetSettings() +- **Key files:** + - `src/NoteBookmark.AIServices/ResearchService.cs` - Handles web search with domain filtering, returns PostSuggestions + - `src/NoteBookmark.AIServices/SummaryService.cs` - Generates text summaries from content + - `src/NoteBookmark.Domain/Settings.cs` - Configuration entity (ITableEntity for Azure Table Storage) + - `src/NoteBookmark.Api/SettingEndpoints.cs` - API endpoints that mask sensitive fields (API key) + - `src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor` - UI for app configuration + +### Migration to Microsoft AI Agent Framework +- **Pattern for simple chat:** Use `ChatClientAgent` with `IChatClient` from OpenAI SDK +- **Pattern for structured output:** Use `AIJsonUtilities.CreateJsonSchema()` + `ChatOptions.ResponseFormat` +- **Provider flexibility:** OpenAI client supports custom endpoints (Reka, OpenAI, Claude, Ollama) +- **Critical:** Avoid DateTime in structured output schemas - use strings for dates +- **Configuration strategy:** Add AIApiKey, AIBaseUrl, AIModelName to Settings; maintain backward compatibility with env vars + +### Security Considerations +- **API Key protection:** GetSettings endpoint masks API key with "********" to prevent client exposure +- **Storage:** API Key stored in plain text in Azure Table Storage (acceptable - protected by Azure auth) +- **SaveSettings logic:** Preserves existing API key when masked value is received +- **Trade-off:** Custom encryption not implemented due to key management complexity vs. limited benefit + +### Project Structure +- **Aspire-based:** Uses .NET Aspire orchestration (AppHost) +- **Service defaults:** Resilience policies configured via ServiceDefaults +- **Storage:** Azure Table Storage for all entities including Settings +- **UI:** FluentUI Blazor components, interactive server render mode +- **Branch strategy:** v-next is active development branch (ahead of main) + +### Dependency Injection Patterns +- **API:** IDataStorageService registered as scoped, endpoints instantiate directly with TableServiceClient/BlobServiceClient +- **BlazorApp:** AI services registered as transient with custom factory functions for settings provider +- **Settings provider:** Async function that fetches from API with fallback to IConfiguration + +📌 **Team Update (2026-02-14):** Migration to Microsoft AI Agent Framework consolidated and finalized. Decision merged from Ripley (plan), Newt (settings), Hudson (tests), and Hicks (implementation) — decided by Ripley, Newt, Hudson, Hicks + +### Authentication Architecture +- **Keycloak Integration:** Using Aspire.Hosting.Keycloak (hosting) + Aspire.Keycloak.Authentication (client) +- **Private Website Pattern:** Home page public, all other pages require authentication +- **OpenID Connect Flow:** Code flow with PKCE for Blazor interactive server +- **Realm Configuration:** JSON import at AppHost startup with pre-configured client and admin user +- **User Provisioning:** Admin-only (registration disabled) - selected users only +- **Layout Strategy:** MinimalLayout (public) vs MainLayout (authenticated with NavMenu) +- **Development vs Production:** + - Dev: `RequireHttpsMetadata = false` for local Keycloak container + - Prod: Explicit Authority URL pointing to external Keycloak instance +- **Key Files:** + - `src/NoteBookmark.AppHost/AppHost.cs` - Keycloak resource configuration + - `src/NoteBookmark.AppHost/Realms/*.json` - Realm import definitions + - `src/NoteBookmark.BlazorApp/Program.cs` - OpenID Connect registration + - `src/NoteBookmark.BlazorApp/Components/Routes.razor` - CascadingAuthenticationState + - `src/NoteBookmark.BlazorApp/Components/Layout/MinimalLayout.razor` - Public layout + +📌 **Team Update (2026-02-16):** Keycloak Authentication Architecture consolidated. Ripley designed architecture, Hicks added Keycloak resource to AppHost, Newt implemented OpenID Connect in BlazorApp — decided by Ripley, Hicks, Newt + +### Keycloak Integration Recovery (2026-07-24) +- **State of OIDC client config:** BlazorApp Program.cs has complete OpenID Connect setup (Cookie + OIDC, middleware, endpoints, cascading state). This survived intact. +- **State of auth UI:** LoginDisplay.razor, Login.razor, Logout.razor, Home.razor all exist with correct patterns (AuthorizeView, HttpContext challenge, AllowAnonymous). LoginDisplay has a bug: `forceLoad: false` needs to be `true`. +- **Missing AppHost Keycloak resource:** `Aspire.Hosting.Keycloak` NuGet is referenced in AppHost.csproj but AppHost.cs has no `AddKeycloak()` call or `WithReference(keycloak)` on projects. Container never starts. +- **Missing realm config:** `src/NoteBookmark.AppHost/Realms/` directory doesn't exist. No realm JSON for auto-provisioning. +- **Missing page authorization:** 7 pages (Posts, PostEditor, PostEditorLight, Settings, Search, Summaries, SummaryEditor) lack `@attribute [Authorize]`. Routes.razor uses `RouteView` instead of `AuthorizeRouteView`, so even if attributes were present, they wouldn't be enforced. +- **Missing _Imports.razor directives:** `@using Microsoft.AspNetCore.Authorization` and `@using Microsoft.AspNetCore.Components.Authorization` not in global imports — pages would need per-file using statements. +- **docker-compose gap:** No Keycloak service in docker-compose/docker-compose.yaml. +- **Configuration note:** `appsettings.development.json` has Keycloak config pointing to `localhost:8080`. When Aspire manages the container via `WithReference(keycloak)`, the connection string is injected automatically — hardcoded URL is redundant for Aspire but needed for non-Aspire runs. +- **API auth not in scope:** API project doesn't validate tokens. It's called server-to-server from BlazorApp. Adding API token validation is deferred. +- **PostEditorLight pattern:** Uses `@layout MinimalLayout` (no nav) but still requires authentication — minimal layout ≠ public access. diff --git a/.ai-team/agents/scribe/charter.md b/.ai-team/agents/scribe/charter.md new file mode 100644 index 0000000..d348685 --- /dev/null +++ b/.ai-team/agents/scribe/charter.md @@ -0,0 +1,20 @@ +# Scribe — Session Logger + +## Role +Silent team member. You log sessions, merge decisions, and maintain team memory. You never speak to the user. + +## Responsibilities +- Log session activity to `.ai-team/log/` +- Merge decision inbox files into `.ai-team/decisions.md` +- Deduplicate and consolidate decisions +- Propagate team updates to agent histories +- Commit `.ai-team/` changes with proper messages +- Summarize and archive old history entries when files grow large + +## Boundaries +- Never respond to the user directly +- Never make technical decisions — only record them +- Always use file ops, never SQL (cross-platform compatibility) + +## Model +**Preferred:** claude-haiku-4.5 (mechanical file operations) diff --git a/.ai-team/agents/scribe/history.md b/.ai-team/agents/scribe/history.md new file mode 100644 index 0000000..bc32725 --- /dev/null +++ b/.ai-team/agents/scribe/history.md @@ -0,0 +1,11 @@ +# Scribe's History + +## Project Learnings (from import) + +**Project:** NoteBookmark +**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework +**Owner:** fboucher (fboucher@outlook.com) + +This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. + +## Learnings diff --git a/.ai-team/casting/history.json b/.ai-team/casting/history.json new file mode 100644 index 0000000..f8fac63 --- /dev/null +++ b/.ai-team/casting/history.json @@ -0,0 +1,22 @@ +{ + "universe_usage_history": [ + { + "assignment_id": "notebookmark-initial", + "universe": "Alien", + "timestamp": "2026-02-14T15:02:00Z" + } + ], + "assignment_cast_snapshots": { + "notebookmark-initial": { + "universe": "Alien", + "agent_map": { + "ripley": "Ripley", + "hicks": "Hicks", + "newt": "Newt", + "hudson": "Hudson", + "scribe": "Scribe" + }, + "created_at": "2026-02-14T15:02:00Z" + } + } +} diff --git a/.ai-team/casting/policy.json b/.ai-team/casting/policy.json new file mode 100644 index 0000000..a2faf0c --- /dev/null +++ b/.ai-team/casting/policy.json @@ -0,0 +1,40 @@ +{ + "casting_policy_version": "1.1", + "universe": "Alien", + "allowlist_universes": [ + "The Usual Suspects", + "Reservoir Dogs", + "Alien", + "Ocean's Eleven", + "Arrested Development", + "Star Wars", + "The Matrix", + "Firefly", + "The Goonies", + "The Simpsons", + "Breaking Bad", + "Lost", + "Marvel Cinematic Universe", + "DC Universe", + "Monty Python", + "Doctor Who", + "Attack on Titan", + "The Lord of the Rings", + "Succession", + "Severance", + "Adventure Time", + "Futurama", + "Seinfeld", + "The Office", + "Cowboy Bebop", + "Fullmetal Alchemist", + "Stranger Things", + "The Expanse", + "Arcane", + "Ted Lasso", + "Dune" + ], + "universe_capacity": { + "Alien": 8 + } +} diff --git a/.ai-team/casting/registry.json b/.ai-team/casting/registry.json new file mode 100644 index 0000000..057f3af --- /dev/null +++ b/.ai-team/casting/registry.json @@ -0,0 +1,46 @@ +{ + "agents": { + "ripley": { + "persistent_name": "Ripley", + "universe": "Alien", + "created_at": "2026-02-14T15:02:00Z", + "legacy_named": false, + "status": "active" + }, + "hicks": { + "persistent_name": "Hicks", + "universe": "Alien", + "created_at": "2026-02-14T15:02:00Z", + "legacy_named": false, + "status": "active" + }, + "newt": { + "persistent_name": "Newt", + "universe": "Alien", + "created_at": "2026-02-14T15:02:00Z", + "legacy_named": false, + "status": "active" + }, + "hudson": { + "persistent_name": "Hudson", + "universe": "Alien", + "created_at": "2026-02-14T15:02:00Z", + "legacy_named": false, + "status": "active" + }, + "scribe": { + "persistent_name": "Scribe", + "universe": "Alien", + "created_at": "2026-02-14T15:02:00Z", + "legacy_named": false, + "status": "active" + }, + "bishop": { + "persistent_name": "Bishop", + "universe": "Alien", + "created_at": "2026-02-14T15:24:53Z", + "legacy_named": false, + "status": "active" + } + } +} diff --git a/.ai-team/ceremonies.md b/.ai-team/ceremonies.md new file mode 100644 index 0000000..aaa0502 --- /dev/null +++ b/.ai-team/ceremonies.md @@ -0,0 +1,41 @@ +# Ceremonies + +> Team meetings that happen before or after work. Each squad configures their own. + +## Design Review + +| Field | Value | +|-------|-------| +| **Trigger** | auto | +| **When** | before | +| **Condition** | multi-agent task involving 2+ agents modifying shared systems | +| **Facilitator** | lead | +| **Participants** | all-relevant | +| **Time budget** | focused | +| **Enabled** | ✅ yes | + +**Agenda:** +1. Review the task and requirements +2. Agree on interfaces and contracts between components +3. Identify risks and edge cases +4. Assign action items + +--- + +## Retrospective + +| Field | Value | +|-------|-------| +| **Trigger** | auto | +| **When** | after | +| **Condition** | build failure, test failure, or reviewer rejection | +| **Facilitator** | lead | +| **Participants** | all-involved | +| **Time budget** | focused | +| **Enabled** | ✅ yes | + +**Agenda:** +1. What happened? (facts only) +2. Root cause analysis +3. What should change? +4. Action items for next iteration diff --git a/.ai-team/decisions.md b/.ai-team/decisions.md new file mode 100644 index 0000000..8f2a122 --- /dev/null +++ b/.ai-team/decisions.md @@ -0,0 +1,86 @@ +# Decisions + +> Canonical decision ledger. All architectural, scope, and process decisions live here. + +### 2026-02-14: AI Agent Framework Migration (consolidated) + +**By:** Ripley, Hudson, Newt, Bishop + +**What:** Completed migration of NoteBookmark.AIServices from Reka SDK to Microsoft.Agents.AI provider-agnostic framework. Hudson implemented server-side AISettingsProvider to retrieve unmasked secrets from Azure Table Storage for internal services while API masks credentials for external clients. Newt enhanced DateOnlyJsonConverter for resilient date parsing across all AI provider formats. Bishop approved final implementation after security fixes. + +**Why:** +- Standardize on provider-agnostic Microsoft.Agents.AI abstraction layer +- Enable multi-provider support (OpenAI, Claude, Ollama, Reka, etc.) +- Add configurable provider settings through UI and Settings entity in Azure Table Storage +- Resolve configuration wiring: server-side services must access unmasked secrets from database while maintaining API security boundary +- Enhance resilience to AI-generated date formats (ISO8601, Unix Epoch, custom formats, unexpected types) +- Security: Prevent accidental exposure of API keys to client-side applications + +**Implementation:** Dependencies updated (Removed Reka.SDK, Added Microsoft.Agents.AI). Services refactored with ResearchService using structured JSON output and SummaryService using chat completion. Configuration via AISettingsProvider delegate with fallback hierarchy: Database → Environment Variables. API endpoints mask API keys with "********" for security. Test coverage: 31 AI service tests + 153 API tests (all passing). + +**Impact:** Multi-provider support enabled, configuration wiring works correctly, API key security maintained, AI output resilience improved. + +### 2026-02-16: Keycloak Authentication & Orchestration (consolidated) + +**By:** Ripley, Hicks, Newt + +**What:** Complete Keycloak authentication integration for NoteBookmark private website including authentication architecture, authorization enforcement, dual-mode orchestration, and logout flow. Ripley designed overall strategy (AppHost resource, BlazorApp OpenID Connect, production considerations). Hicks implemented AppHost Keycloak resource with data persistence on port 8080, realm import from ./Realms/, docker-compose service definition with persistent volume, split dev/prod modes to eliminate port conflicts, and fixed logout flow async token retrieval. Newt implemented authorization enforcement: AuthorizeRouteView in Routes.razor, [Authorize] attributes on all protected pages (Posts, Summaries, Settings, Search, Editors), [AllowAnonymous] on public pages, and fixed authentication challenge via HttpContext.ChallengeAsync() for Blazor Server compatibility. Also fixed returnUrl navigation, header layout spacing with FluentStack, and ensured all redirect pages use relative paths. + +**Why:** +- Security requirement: Convert public application to private, authenticated-only access +- User directive: Only selected users can login +- Leverage Aspire's native Keycloak integration for development container orchestration +- Use industry-standard OpenID Connect for Blazor interactive server applications +- Maintain development/production separation with explicit Authority configuration (dev: Aspire-managed, prod: docker-compose standalone) +- Eliminate port conflicts between AddDockerComposeEnvironment() and AddKeycloak() by branching on Environment.IsDevelopment() +- Enterprise-grade identity management with user administration +- Blazor Server authentication must trigger server-side via HttpContext, not client-side navigation +- Keycloak logout requires `id_token_hint` parameter which demands async/await pattern in Blazor Server context +- Route-level authorization prevents unauthorized access to all non-home pages + +**Architecture:** +- **AppHost (Development):** `AddKeycloak("keycloak", 8080).WithDataVolume()` resource, BlazorApp references keycloak with WaitFor, realm import from ./Realms/notebookmark-realm.json, branches on Environment.IsDevelopment() +- **AppHost (Production):** No Keycloak resource; expects docker-compose to manage all containers independently +- **docker-compose:** Keycloak 26.1 service on port 8080, quay.io/keycloak/keycloak image, start-dev mode, admin credentials via environment variables, named volume for data persistence +- **BlazorApp:** OpenID Connect authentication with Cookie scheme, AddCascadingAuthenticationState, AddHttpContextAccessor for challenge flow, UseAuthentication/UseAuthorization middleware +- **Authorization:** Routes.razor uses AuthorizeRouteView with CascadingAuthenticationState, Home/Login/Logout pages marked [AllowAnonymous], all other pages require [Authorize] +- **UI:** LoginDisplay component in MainLayout header using FluentStack for proper spacing, Login.razor uses HttpContext.ChallengeAsync() with query string returnUrl, Logout.razor triggers sign-out challenge with async token retrieval +- **Configuration:** Keycloak settings (Authority, ClientId, ClientSecret) injected via Aspire service discovery in development, explicit appsettings.json values for production +- **Logout Flow:** OnRedirectToIdentityProviderForSignOut event handler uses async/await for GetTokenAsync("id_token"), properly passes id_token_hint to Keycloak for clean session termination + +**Implementation Status:** +- AppHost build succeeded, docker-compose validated +- All protected pages secured with [Authorize] +- AuthorizeRouteView routing enforcement active +- HttpContext.ChallengeAsync() pattern working without NavigationException +- Login/logout flow properly handles return URLs and id_token_hint parameter +- Headers use FluentStack to prevent component overlap +- Dual-mode architecture eliminates port conflicts, clarifies dev vs prod separation + +**Next Steps:** Create Keycloak realm "notebookmark" with client configuration, configure admin user, test full authentication flow end-to-end. + +### 2026-02-14: Code Review — Bishop Oversight Standard + +**By:** Frank, Bishop + +**What:** Established that Bishop reviews all code changes going forward as part of standard quality assurance process. + +**Why:** User directive — ensure code quality and architectural consistency across team. + +### 2026-02-14: Resilient Date Parsing + +**By:** Bishop + +**What:** Enhanced `DateOnlyJsonConverter` to handle all possible JSON types that AI providers might return: strings (ISO dates, custom formats), numbers (Unix timestamps), booleans, objects, and arrays. Gracefully handles any JsonTokenType, normalizes parseable dates to "yyyy-MM-dd", preserves unparseable strings as-is, falls back to null for complex types. + +**Why:** AI models frequently hallucinate data formats or return unexpected types (null, boolean). User reported JsonException when AI returned unexpected type. Best-effort parsing allows application to function with partial data. + +### 2026-02-14: Settings UI and Database Configuration + +**By:** Bishop + +**What:** Identified disconnect between UI Settings form (saves to Azure Table Storage) and AI Service configuration (reads from IConfiguration/environment variables). No mechanism to bridge database settings to IConfiguration used by services. + +**Why:** Configuration changes in UI do not apply to AI services without environment variable updates from database (not implemented). + +**Resolution:** Hudson implemented AISettingsProvider that reads directly from Azure Table Storage, creating proper bridge between UI and services while maintaining API security boundary. diff --git a/.ai-team/log/2026-02-14-ai-agent-migration.md b/.ai-team/log/2026-02-14-ai-agent-migration.md new file mode 100644 index 0000000..327aec6 --- /dev/null +++ b/.ai-team/log/2026-02-14-ai-agent-migration.md @@ -0,0 +1,42 @@ +# Session Log: 2026-02-14 AI Agent Migration + +**Requested by:** fboucher + +## Summary + +Scribe processed AI team decisions and consolidated session artifacts. + +## Activities + +**Inbox Merged (4 files):** +- Hicks: Completed migration to Microsoft AI Agent Framework +- Hudson: Test coverage for AI services (31 unit tests) +- Newt: AI provider configuration in Settings +- Ripley: Migration plan and framework analysis + +**Consolidation:** +- Identified 4 overlapping decisions covering the same AI services migration initiative +- Synthesized single consolidated decision block: "2026-02-14: Migration to Microsoft AI Agent Framework (consolidated)" +- Merged rationale from all authors; preserved implementation details from Hicks, test coverage from Hudson, settings design from Newt, and technical analysis from Ripley + +**Decisions Written:** +- .ai-team/decisions.md updated with consolidated decision record + +**Files Deleted:** +- .ai-team/decisions/inbox/hicks-ai-agent-migration-complete.md +- .ai-team/decisions/inbox/hudson-ai-services-test-coverage.md +- .ai-team/decisions/inbox/newt-ai-provider-settings.md +- .ai-team/decisions/inbox/ripley-ai-agent-migration.md + +## Decision Summary + +**Consolidation:** Migration to Microsoft AI Agent Framework +- From Reka SDK to Microsoft.Agents.AI (provider-agnostic) +- Includes configurable settings, comprehensive test coverage +- Backward compatible; web search domain filtering removed +- Status: Implementation complete + +## Next Steps + +- Agents affected by this decision will receive history notifications +- Session ready for git commit diff --git a/.ai-team/log/2026-02-14-bishop-review-date-fix.md b/.ai-team/log/2026-02-14-bishop-review-date-fix.md new file mode 100644 index 0000000..989d265 --- /dev/null +++ b/.ai-team/log/2026-02-14-bishop-review-date-fix.md @@ -0,0 +1,20 @@ +# Session Log: Bishop Review — Date Parsing Fix + +**Date:** 2026-02-14 +**Requested by:** frank +**Participants:** Bishop, Hicks +**Session Type:** Code Review + +## Summary +Bishop reviewed Hicks's defensive date parsing implementation for JSON deserialization. Enhanced `DateOnlyJsonConverter` to handle all possible JSON types (strings, numbers, booleans, objects, arrays) that AI providers might return. + +## Outcome +✅ **Approved** — The defensive date parsing strategy is sound. Graceful handling of unpredictable AI output formats prevents service failures. + +## Directive Captured +Bishop will review all code changes going forward (user directive: "yes, always"). + +## Impact +- Resilient JSON deserialization for AI-generated date fields +- Eliminates `JsonException` failures on unexpected type conversions +- Maintains backward compatibility with expected formats diff --git a/.ai-team/log/2026-02-16-docker-compose-docs.md b/.ai-team/log/2026-02-16-docker-compose-docs.md new file mode 100644 index 0000000..546f1c2 --- /dev/null +++ b/.ai-team/log/2026-02-16-docker-compose-docs.md @@ -0,0 +1,19 @@ +# Session: Docker-Compose Deployment Documentation + +**Requested by:** fboucher + +## Summary + +User changed direction mid-session: initially planned to remove AddDockerComposeEnvironment from AppHost, but changed course to keep it and create documentation instead. Final decision was to implement dual-mode architecture—development uses Aspire's native Keycloak, production uses docker-compose standalone. + +## Work Completed + +1. **Hicks:** Removed AddDockerComposeEnvironment() from AppHost to resolve port conflicts. Split Keycloak into dev/prod modes: development uses Aspire-managed lifecycle, production expects docker-compose to manage containers independently. + +2. **Hicks:** Fixed Keycloak logout flow by converting OnRedirectToIdentityProviderForSignOut event handler to async and properly awaiting GetTokenAsync("id_token") call—resolves "Missing parameters: id_token_hint" error. + +## Decisions Made + +- Keep AddDockerComposeEnvironment in docker-compose.yaml; document it for production users instead of removing it +- Implement dual-mode: AppHost branches on Environment.IsDevelopment() for Keycloak configuration +- Production deployment uses docker-compose.yaml independently without AppHost interference diff --git a/.ai-team/log/2026-02-16-keycloak-auth.md b/.ai-team/log/2026-02-16-keycloak-auth.md new file mode 100644 index 0000000..bbadb64 --- /dev/null +++ b/.ai-team/log/2026-02-16-keycloak-auth.md @@ -0,0 +1,21 @@ +# Session Log — 2026-02-16 + +**Requested by:** fboucher + +## Team Activity + +**Ripley:** Designed Keycloak authentication architecture for private website access. Defined AppHost layer (Keycloak resource, realm configuration), BlazorApp layer (OpenID Connect), and production deployment considerations. + +**Hicks:** Added Keycloak container resource to Aspire AppHost with data persistence. Configured API and Blazor app references. Using Aspire.Hosting.Keycloak v13.1.0-preview. + +**Newt:** Implemented OpenID Connect authentication guards in Blazor app. Added LoginDisplay component, protected pages with @Authorize attribute, configured cascading authentication state and OIDC middleware. Only home page remains public. + +**Hudson:** Implemented server-side AISettingsProvider to retrieve unmasked AI configuration from Azure Table Storage, bypassing the HTTP API's client-facing masking. Ensures AI services receive real credentials from user settings. + +**Bishop:** Completed final review of AI Agent Framework migration. Approved Hudson's fix for configuration wiring. All 184 tests passing. Migration ready for deployment. + +## Decisions Merged + +- Merged 11 decision files from inbox into decisions.md +- Consolidated overlapping decisions on Keycloak architecture, authentication, and AI services configuration +- Deduplicating exact matches and synthesizing overlapping blocks diff --git a/.ai-team/log/2026-02-16-scribe-session.md b/.ai-team/log/2026-02-16-scribe-session.md new file mode 100644 index 0000000..faf6b13 --- /dev/null +++ b/.ai-team/log/2026-02-16-scribe-session.md @@ -0,0 +1,38 @@ +# Session Log — 2026-02-16 + +**Requested by:** fboucher + +## What Happened + +1. **Merged 5 decision inbox files** into decisions.md: + - hicks-keycloak-apphost-implementation.md + - newt-authorization-protection.md + - newt-blazor-auth-challenge-pattern.md + - newt-keycloak-auth-fixes.md + - ripley-keycloak-integration-strategy.md + +2. **Consolidated overlapping decisions:** + - Identified that all 5 inbox files relate to the Keycloak authentication architecture already consolidated on 2026-02-16 + - Merged new details from Hicks, Newt, and Ripley into enhanced "2026-02-16: Keycloak Authentication Architecture" block + - Removed Ripley's strategy document (superseded by implementation records from Hicks/Newt) + +3. **Updated decisions.md** with merged content from inbox, removed exact duplicate entries + +## Key Decisions Recorded + +- **Keycloak AppHost implementation:** Hicks added Keycloak container resource with data volume, proper service discovery, and docker-compose configuration +- **Authorization protection:** Newt implemented AuthorizeRouteView with [Authorize] attributes across protected pages +- **Blazor auth challenge pattern:** Newt switched from NavigationManager.NavigateTo() to HttpContext.ChallengeAsync() for Blazor Server compatibility +- **Keycloak bug fixes:** Newt fixed returnUrl navigation, layout spacing, and AllowAnonymous attributes +- **Integration strategy:** Ripley provided overall architecture and gap analysis for complete authentication restoration + +## Files Modified + +- `.ai-team/log/2026-02-16-scribe-session.md` — created +- `.ai-team/decisions.md` — merged 5 inbox decisions, consolidated overlapping blocks +- `.ai-team/decisions/inbox/*` — 5 files deleted after merge + +## No Further Actions + +- No agent history updates required (decisions are team-wide) +- No history.md archival needed (all within size bounds) diff --git a/.ai-team/routing.md b/.ai-team/routing.md new file mode 100644 index 0000000..71bb612 --- /dev/null +++ b/.ai-team/routing.md @@ -0,0 +1,11 @@ +# Routing + +| Signal | Agent | Examples | +|--------|-------|----------| +| Architecture, design decisions, coordination | Ripley | "Design the auth flow", "Review architecture" | +| Backend, AI services, .NET core, APIs, C# backend | Hicks | "Migrate AI services", "Add API endpoint", "Configure DI" | +| Frontend, Blazor, UI components, pages, forms | Newt | "Build settings page", "Update UI", "Add form validation" | +| Tests, quality, edge cases, validation | Hudson | "Write tests", "Test coverage", "Verify edge cases" | +| Code review, security review, quality gates | Bishop | "Review this code", "Check for security issues", "Review the changes" | +| Session logging, decisions, memory (silent) | Scribe | (auto-triggered after agent work) | +| Work queue, backlog monitoring | Ralph | "Ralph, go", "Keep working", "Work until done" | diff --git a/.ai-team/skills/aspire-keycloak-integration/SKILL.md b/.ai-team/skills/aspire-keycloak-integration/SKILL.md new file mode 100644 index 0000000..302ce96 --- /dev/null +++ b/.ai-team/skills/aspire-keycloak-integration/SKILL.md @@ -0,0 +1,463 @@ +--- +name: "aspire-keycloak-integration" +description: "Integrate Keycloak authentication with Aspire-hosted applications using OpenID Connect" +domain: "security, authentication, aspire" +confidence: "high" +source: "earned" +--- + +## Context + +When building Aspire applications that require authentication, Keycloak provides an open-source Identity and Access Management solution. Aspire has first-class support for Keycloak through hosting and client integrations. + +Use this pattern when: +- Building private/authenticated applications with Aspire +- Need to control user access (admin-managed users) +- Want containerized local development with production-ready auth +- Require OpenID Connect for web applications + +## Patterns + +### AppHost Configuration (Hosting Integration) + +1. **Add NuGet Package:** `Aspire.Hosting.Keycloak` to AppHost project + +2. **Basic Keycloak Resource:** +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var keycloak = builder.AddKeycloak("keycloak", 8080); + +var blazorApp = builder.AddProject("blazor-app") + .WithReference(keycloak) + .WaitFor(keycloak); +``` + +3. **With Realm Import (Recommended):** +```csharp +var keycloak = builder.AddKeycloak("keycloak", 8080) + .WithRealmImport("./Realms"); // Import realm JSON files on startup +``` + +4. **With Data Persistence:** +```csharp +var keycloak = builder.AddKeycloak("keycloak", 8080) + .WithDataVolume() // Persist data across container restarts + .WithRealmImport("./Realms"); +``` + +5. **With Custom Admin Credentials:** +```csharp +var username = builder.AddParameter("keycloak-admin"); +var password = builder.AddParameter("keycloak-password", secret: true); + +var keycloak = builder.AddKeycloak("keycloak", 8080, username, password); +``` + +### Blazor App Configuration (Client Integration) + +1. **Add NuGet Package:** `Aspire.Keycloak.Authentication` to Blazor project + +2. **Register OpenID Connect Authentication (Program.cs):** +```csharp +builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) + .AddKeycloakOpenIdConnect( + serviceName: "keycloak", // Must match AppHost resource name + realm: "my-realm", + options => + { + options.ClientId = "my-blazor-app"; + options.ResponseType = OpenIdConnectResponseType.Code; + options.Scope.Add("profile"); + + // Development only - disable HTTPS validation + if (builder.Environment.IsDevelopment()) + { + options.RequireHttpsMetadata = false; + } + }); + +// Add authentication services +builder.Services.AddAuthorization(); +builder.Services.AddCascadingAuthenticationState(); +``` + +3. **Add Middleware (after UseRouting, before UseAntiforgery):** +```csharp +app.UseAuthentication(); +app.UseAuthorization(); +``` + +4. **Wrap Router with Authentication State (Routes.razor or App.razor):** +```razor + + + + + + + + + + + +``` + +### Realm Configuration (JSON Import) + +**File:** `src/AppHost/Realms/my-realm.json` + +```json +{ + "realm": "my-realm", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "clients": [ + { + "clientId": "my-blazor-app", + "protocol": "openid-connect", + "publicClient": true, + "redirectUris": [ + "http://localhost:*/signin-oidc", + "https://*.azurewebsites.net/signin-oidc" + ], + "webOrigins": ["+"], + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false + } + ], + "users": [ + { + "username": "admin", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "admin123", + "temporary": false + } + ] + } + ] +} +``` + +**Key Settings:** +- `registrationAllowed: false` - For private applications (admin creates users) +- `publicClient: true` - For SPAs/Blazor (no client secret needed in browser) +- `redirectUris` - Wildcard patterns for dev + production URLs +- `webOrigins: ["+"]` - Allow same-origin requests + +### Production Configuration + +**Development (local container):** +```csharp +if (builder.Environment.IsDevelopment()) +{ + options.RequireHttpsMetadata = false; +} +``` + +**Production (external Keycloak):** +```csharp +if (!builder.Environment.IsDevelopment()) +{ + options.Authority = "https://keycloak.mydomain.com/realms/my-realm"; + // RequireHttpsMetadata defaults to true +} +``` + +**AppHost connection string for production:** +```csharp +builder.AddConnectionString("keycloak", "https://keycloak.mydomain.com"); +``` + +## Examples + +### Mixed Public/Private Pages + +**Public Home Page:** +```razor +@page "/" +@layout MinimalLayout + +

Welcome

+

Sign in to continue

+``` + +**Protected Page:** +```razor +@page "/dashboard" +@attribute [Authorize] + +

Dashboard

+ + +

Hello, @context.User.Identity.Name!

+
+
+``` + +**Conditional Navigation (NavMenu.razor):** +```razor + + + Dashboard + Settings + + + Sign In + + +``` + +### Login/Logout Buttons + +```razor +@inject NavigationManager Navigation + + + + Logout + + + Login + + + +@code { + private void LoginAsync() + { + Navigation.NavigateTo("/login", forceLoad: true); + } + + private void LogoutAsync() + { + Navigation.NavigateTo("/logout", forceLoad: true); + } +} +``` + +## Anti-Patterns + +### ❌ Don't: Use HTTP in production +```csharp +// NEVER do this in production +options.RequireHttpsMetadata = false; +``` + +### ❌ Don't: Store client secrets in code +```csharp +// Bad - secret in code +options.ClientSecret = "my-secret-key"; + +// Good - use parameter or Key Vault +var clientSecret = builder.AddParameter("keycloak-client-secret", secret: true); +``` + +### ❌ Don't: Enable public registration for private apps +```json +// Bad for private applications +{ + "realm": "my-realm", + "registrationAllowed": true // Anyone can register! +} +``` + +### ❌ Don't: Forget WaitFor dependency +```csharp +// Bad - app might start before Keycloak ready +var blazorApp = builder.AddProject("blazor-app") + .WithReference(keycloak); // Missing .WaitFor(keycloak) +``` + +### ✅ Do: Use explicit Authority in production +```csharp +// Good - explicit configuration +if (!builder.Environment.IsDevelopment()) +{ + options.Authority = builder.Configuration["Keycloak:Authority"]; +} +``` + +### ✅ Do: Persist Keycloak data in development +```csharp +// Good - preserve realm config across restarts +var keycloak = builder.AddKeycloak("keycloak", 8080) + .WithDataVolume(); +``` + +### ✅ Do: Use realm import for consistent setup +```csharp +// Good - version-controlled realm configuration +var keycloak = builder.AddKeycloak("keycloak", 8080) + .WithRealmImport("./Realms"); +``` + +### ✅ Do: Use confidential client for server-side Blazor +Server-rendered Blazor apps can safely hold a client secret. Use confidential (non-public) client type for stronger security than `publicClient: true`. + +### ✅ Do: Verify the full auth chain +Three things must all be present for Keycloak auth to work: +1. **AppHost resource** — `AddKeycloak()` + `WithReference()` + `WaitFor()` on dependent projects +2. **Routes enforcement** — `AuthorizeRouteView` in Routes.razor (not plain `RouteView`) +3. **Page attributes** — `@attribute [Authorize]` on every non-public page + +Missing any one of these silently degrades to unauthenticated access. + +## Docker Compose Integration Pattern + +When using both Aspire and docker-compose deployment (dual orchestration): + +### 1. AppHost Declaration + +```csharp +var keycloak = builder.AddKeycloak("keycloak", port: 8080) + .WithDataVolume(); + +builder.AddProject("blazor-app") + .WithReference(keycloak) + .WaitFor(keycloak) + .WithComputeEnvironment(compose); // docker-compose deployment +``` + +### 2. Docker Compose Service + +```yaml +services: + keycloak: + image: "quay.io/keycloak/keycloak:26.1" + container_name: "app-keycloak" + command: ["start-dev"] + environment: + KEYCLOAK_ADMIN: "admin" + KEYCLOAK_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD:-admin}" + KC_HTTP_PORT: "8080" + KC_HOSTNAME_STRICT: "false" # Dev only + KC_HOSTNAME_STRICT_HTTPS: "false" # Dev only + KC_HTTP_ENABLED: "true" # Dev only + ports: + - "8080:8080" + volumes: + - keycloak-data:/opt/keycloak/data + networks: + - "aspire" + + blazor-app: + depends_on: + keycloak: + condition: "service_started" + environment: + services__keycloak__http__0: "http://keycloak:8080" + Keycloak__Authority: "${KEYCLOAK_AUTHORITY:-http://localhost:8080/realms/my-realm}" + Keycloak__ClientId: "${KEYCLOAK_CLIENT_ID:-my-client}" + Keycloak__ClientSecret: "${KEYCLOAK_CLIENT_SECRET}" + +volumes: + keycloak-data: + driver: "local" +``` + +### 3. Environment Variable Defaults + +Use `${VAR:-default}` syntax for optional variables with fallback: +- `${KEYCLOAK_ADMIN_PASSWORD:-admin}` — defaults to "admin" if not set +- `${KEYCLOAK_AUTHORITY:-http://localhost:8080/realms/my-realm}` — dev default + +### 4. Service Discovery Mapping + +Aspire service references translate to docker-compose environment variables: +- AppHost: `.WithReference(keycloak)` +- docker-compose: `services__keycloak__http__0: "http://keycloak:8080"` + +This enables service-to-service communication within the docker network. + +## Dual-Mode Pattern: Development vs Production + +**Problem:** Port conflicts when both AppHost and docker-compose try to manage Keycloak on same port. + +**Solution:** Conditional resource configuration based on environment: + +### Development Mode (Aspire-managed) + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +if (builder.Environment.IsDevelopment()) +{ + var keycloak = builder.AddKeycloak("keycloak", port: 8080) + .WithDataVolume(); + + var noteStorage = builder.AddAzureStorage("storage") + .RunAsEmulator(); + + var api = builder.AddProject("api") + .WithReference(noteStorage); + + builder.AddProject("blazor-app") + .WithReference(api) + .WithReference(keycloak) // Aspire manages Keycloak + .WaitFor(keycloak); +} +``` + +**Benefits:** +- Aspire automatically starts/stops Keycloak +- Service discovery works automatically +- Storage emulator for local development +- Full integration with AppHost dashboard + +### Production Mode (docker-compose standalone) + +```csharp +else +{ + // No Keycloak resource - docker-compose manages it + var noteStorage = builder.AddAzureStorage("storage"); + + var api = builder.AddProject("api") + .WithReference(noteStorage); + + builder.AddProject("blazor-app") + .WithReference(api); + // No Keycloak reference - uses environment variables from docker-compose +} +``` + +**Benefits:** +- No port conflicts between AppHost and docker-compose +- docker-compose.yaml runs independently +- BlazorApp reads Keycloak config from environment variables +- Supports Azure deployment without code changes + +### Configuration Flow + +**Development:** +1. Run AppHost → Aspire starts Keycloak container +2. Service discovery injects Keycloak connection to BlazorApp +3. BlazorApp connects to `http://localhost:8080` + +**Production:** +1. Run `docker-compose up` → Standalone Keycloak container starts +2. BlazorApp reads `Keycloak:Authority`, `Keycloak:ClientId` from environment +3. BlazorApp connects to Keycloak via docker network or external URL + +### Key Points + +✅ **Do:** Split AppHost into dev/prod branches when orchestration differs +✅ **Do:** Keep docker-compose.yaml production-ready (works standalone) +✅ **Do:** Use environment variables in docker-compose for configuration +✅ **Don't:** Try to use both AppHost Keycloak and docker-compose Keycloak simultaneously + +## Implementation Updated (2026-02-16) + +Added comprehensive docker-compose integration pattern with: +- Keycloak 26.1 container configuration (latest stable) +- Environment variable defaults and overrides +- Volume persistence setup +- Service dependency orchestration +- Configuration flow from AppHost → docker-compose → application +- **NEW:** Dual-mode pattern for dev (Aspire) vs prod (docker-compose) orchestration separation + +**Testing:** Validated with `docker-compose config --quiet` (passed). diff --git a/.ai-team/skills/aspire-third-party-integration/SKILL.md b/.ai-team/skills/aspire-third-party-integration/SKILL.md new file mode 100644 index 0000000..cff7f64 --- /dev/null +++ b/.ai-team/skills/aspire-third-party-integration/SKILL.md @@ -0,0 +1,140 @@ +--- +name: "aspire-third-party-integration" +description: "Patterns for integrating third-party services (databases, auth, messaging) into .NET Aspire AppHost" +domain: "aspire-hosting" +confidence: "low" +source: "earned" +--- + +## Context +.NET Aspire provides hosting integrations for third-party services through NuGet packages (e.g., Aspire.Hosting.PostgreSQL, Aspire.Hosting.RabbitMQ, Aspire.Hosting.Keycloak). These packages allow you to add containerized or cloud-based services to your AppHost and reference them from your application projects. + +This skill applies when: +- Adding a new external service to an Aspire application +- Following Aspire's resource orchestration patterns +- Integrating authentication, databases, messaging, or storage services + +## Patterns + +### Package Installation Pattern (Central Package Management) +When the project uses Central Package Management (CPM): + +1. **Add version to Directory.Packages.props** + ```xml + + ``` + +2. **Add PackageReference to AppHost.csproj** (version-less) + ```xml + + ``` + +3. **Handle preview versions**: Some Aspire integrations may only have preview versions available. Use the latest preview if stable version doesn't exist (e.g., `13.1.0-preview.1.25616.3`). + +### Resource Declaration Pattern +In AppHost.cs (or AppHost Program.cs): + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +// 1. Declare the resource with configuration +var resourceName = builder.AddServiceName("resource-name", port) + .WithDataVolume() // Optional: persist data + .WithDataBindMount(path) // Alternative: bind mount for data + .WithOtlpExporter(); // Optional: enable telemetry + +// 2. Reference the resource from dependent projects +var api = builder.AddProject("api") + .WithReference(resourceName) // Injects connection string as env var + .WaitFor(resourceName); // Ensures startup order + +var web = builder.AddProject("web") + .WithReference(resourceName) + .WaitFor(resourceName); + +builder.Build().Run(); +``` + +### Data Persistence Options +Choose based on requirements: + +- **No persistence**: Default behavior, data lost on container restart +- **WithDataVolume()**: Docker volume managed by Aspire, survives restarts +- **WithDataBindMount(path)**: Specific host path for data, useful for backups/migration + +### Resource Ordering with WaitFor() +Critical for dependency chains: +```csharp +.WaitFor(storage) // Wait for storage before starting +.WaitFor(database) // Can chain multiple dependencies +``` + +### Authentication/Security Resources +For services like Keycloak, Auth0, etc.: + +1. Default credentials generated and stored in user secrets: + ```json + { + "Parameters:resource-name-password": "GENERATED_PASSWORD" + } + ``` + +2. Access admin console using credentials from secrets +3. Configure realms, clients, users in service admin UI +4. Client projects add authentication packages separately + +## Examples + +### Keycloak Integration +```csharp +// AppHost.cs +var keycloak = builder.AddKeycloak("keycloak", 8080) + .WithDataVolume(); + +var api = builder.AddProject("api") + .WithReference(keycloak) + .WaitFor(keycloak); + +var web = builder.AddProject("web") + .WithReference(keycloak) + .WaitFor(keycloak); +``` + +### PostgreSQL with Volume +```csharp +var postgres = builder.AddPostgres("postgres", 5432) + .WithDataVolume() + .AddDatabase("mydb"); + +var api = builder.AddProject("api") + .WithReference(postgres) + .WaitFor(postgres); +``` + +### RabbitMQ with Telemetry +```csharp +var messaging = builder.AddRabbitMQ("messaging", 5672) + .WithDataVolume() + .WithOtlpExporter(); // Export metrics to Aspire dashboard + +var worker = builder.AddProject("worker") + .WithReference(messaging) + .WaitFor(messaging); +``` + +## Anti-Patterns +- **Not using WaitFor()** — Can cause startup race conditions where apps try to connect before service is ready +- **Hardcoding connection strings** — Use `WithReference()` instead; Aspire injects correct connection string as environment variable +- **Skipping data persistence** — For stateful services (databases, auth), always use `WithDataVolume()` or `WithDataBindMount()` in development +- **Mixing stable and preview versions** — Check available package versions; if only preview exists, use it consistently +- **Forgetting client packages** — Hosting package (Aspire.Hosting.X) is for AppHost only; client projects need separate client packages (Aspire.X.Authentication, etc.) + +## When NOT to Use +- Simple in-process services that don't need orchestration +- Services already running externally (use connection strings directly) +- Production deployments (Aspire hosting is primarily for local development; production uses cloud services or Kubernetes) + +## Related Skills +- Central Package Management (CPM) patterns in .NET +- Docker container orchestration +- Service discovery and configuration in distributed applications diff --git a/.ai-team/skills/blazor-interactive-events/SKILL.md b/.ai-team/skills/blazor-interactive-events/SKILL.md new file mode 100644 index 0000000..e14a537 --- /dev/null +++ b/.ai-team/skills/blazor-interactive-events/SKILL.md @@ -0,0 +1,117 @@ +--- +name: "blazor-interactive-events" +description: "How to enable event handlers in Blazor components with @rendermode" +domain: "blazor, ui, event-handling" +confidence: "medium" +source: "earned" +--- + +## Context +Blazor components with event handlers (OnClick, OnChange, OnSubmit, etc.) require explicit render mode declaration. Without it, event handlers silently fail - buttons appear but don't respond to clicks, dropdowns don't fire change events, etc. This is a common gotcha when creating interactive components. + +## Patterns + +### When @rendermode is Required +- Components with ANY event handler attributes: `OnClick`, `OnChange`, `OnInput`, `OnSubmit`, `OnFocus`, etc. +- Components with two-way binding: `@bind-Value`, `@bind-Text` +- Components calling methods on user interaction +- Shared components used in multiple pages that need interactivity + +### When @rendermode is NOT Required +- Static display components with no user interaction +- Components that only execute lifecycle methods (`OnInitialized`, `OnParametersSet`) without user input +- Pages that immediately redirect (Login.razor, Logout.razor that only call NavigateTo in OnInitialized) + +### Syntax +Place at the TOP of the component file, before other directives: + +```razor +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Components.Authorization +@inject NavigationManager Navigation + +Click Me + +@code { + private void HandleClick() + { + // This will ONLY work with @rendermode InteractiveServer + } +} +``` + +### Alternative: Component-Level Rendermode +In parent components/layouts, you can set rendermode on usage: + +```razor + +``` + +But declaring it in the component itself is clearer and prevents mistakes. + +## Examples + +### LoginDisplay Component (Fixed) +```razor +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Components.Authorization +@inject NavigationManager Navigation + + + + Logout + + + Login + + + +@code { + private void Login() => Navigation.NavigateTo("/login", forceLoad: true); + private void Logout() => Navigation.NavigateTo("/logout", forceLoad: true); +} +``` + +### Login.razor (No rendermode needed) +```razor +@page "/login" +@attribute [AllowAnonymous] +@inject NavigationManager Navigation + +@code { + // OnInitialized runs server-side without user interaction + // No event handlers = no rendermode needed + protected override void OnInitialized() + { + Navigation.NavigateTo("/authentication/login", forceLoad: true); + } +} +``` + +## Anti-Patterns + +### ❌ Missing rendermode with event handlers +```razor +@inject NavigationManager Navigation + +Click + +``` + +### ❌ Rendermode on redirect-only pages +```razor +@rendermode InteractiveServer +@page "/login" +@code { + protected override void OnInitialized() + { + Navigation.NavigateTo("/authentication/login", forceLoad: true); + } +} +``` + +### ✅ Correct: Rendermode only where needed +```razor +@rendermode InteractiveServer +Click +``` diff --git a/.ai-team/skills/blazor-oidc-authentication/SKILL.md b/.ai-team/skills/blazor-oidc-authentication/SKILL.md new file mode 100644 index 0000000..eed34c7 --- /dev/null +++ b/.ai-team/skills/blazor-oidc-authentication/SKILL.md @@ -0,0 +1,187 @@ +# Blazor Server OpenID Connect Authentication + +**Confidence:** High +**Source:** Earned (NoteBookmark) + +Implementing OpenID Connect (OIDC) authentication in Blazor Server applications requires proper middleware configuration, component-level authorization, and cascading authentication state. + +## Pattern: Full OIDC Authentication Setup + +### 1. Dependencies +- Add `Microsoft.AspNetCore.Authentication.OpenIdConnect` package +- Built-in support for Cookie authentication already included + +### 2. Service Configuration (Program.cs) + +```csharp +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; +}) +.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) +.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => +{ + options.Authority = builder.Configuration["Keycloak:Authority"]; + options.ClientId = builder.Configuration["Keycloak:ClientId"]; + options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"]; + options.ResponseType = "code"; + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + + // Configure logout to pass id_token_hint to identity provider + options.Events = new OpenIdConnectEvents + { + OnRedirectToIdentityProviderForSignOut = async context => + { + // CRITICAL: Use async/await, never .Result in Blazor Server + var idToken = await context.HttpContext.GetTokenAsync("id_token"); + if (!string.IsNullOrEmpty(idToken)) + { + context.ProtocolMessage.IdTokenHint = idToken; + } + } + }; +}); + +builder.Services.AddAuthorization(); +builder.Services.AddCascadingAuthenticationState(); +``` + +### 3. Middleware Order (Program.cs) + +**Critical:** Authentication and Authorization must be placed after `UseAntiforgery()` and before `MapRazorComponents()`: + +```csharp +app.UseAuthentication(); +app.UseAuthorization(); +``` + +### 4. Authentication Endpoints + +Map login/logout endpoints that trigger OIDC flow: + +```csharp +app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => +{ + var authProperties = new AuthenticationProperties { RedirectUri = returnUrl ?? "/" }; + await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); +}); + +app.MapGet("/authentication/logout", async (HttpContext context) => +{ + var authProperties = new AuthenticationProperties { RedirectUri = "/" }; + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); +}); +``` + +### 5. Routes Configuration (Routes.razor) + +Replace `RouteView` with `AuthorizeRouteView` and wrap in cascading state. Add custom NotAuthorized UI template: + +```razor +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Authorization + + + + + + + @if (context.User.Identity?.IsAuthenticated != true) + { + + +

Authentication Required

+

You need to be logged in to access this page.

+ + Login + +
+ } + else + { + + +

Access Denied

+

You don't have permission to access this page.

+ + Go to Home + +
+ } +
+
+ +
+
+
+ +@code { + [Inject] private NavigationManager NavigationManager { get; set; } = default!; +} +``` + +### 6. Page-Level Authorization + +Add `@attribute [Authorize]` to protected pages and `@attribute [AllowAnonymous]` to public pages: + +```razor +@page "/protected-page" +@attribute [Authorize] +@using Microsoft.AspNetCore.Authorization +``` + +For public pages (home, login, logout, error): +```razor +@page "/" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization +``` + +### 7. Login Display Component + +Use `` to show different UI based on auth state: + +```razor + + + Hello, @context.User.Identity?.Name + Logout + + + Login + + + +@code { + private void Login() => Navigation.NavigateTo("/login", forceLoad: true); + private void Logout() => Navigation.NavigateTo("/logout", forceLoad: true); +} +``` + +## Key Points + +1. **Configuration Source:** Support both appsettings.json and environment variables (e.g., `Keycloak__Authority`) +2. **Claims Mapping:** Configure `TokenValidationParameters` to map identity provider claims to .NET claims +3. **Force Reload:** Use `forceLoad: true` when navigating to login/logout to trigger full page reload and middleware execution +4. **Imports:** Add `@using Microsoft.AspNetCore.Authorization` and `@using Microsoft.AspNetCore.Components.Authorization` to `_Imports.razor` +5. **NotAuthorized Template:** Distinguish between unauthenticated (show login) and authenticated but unauthorized (show access denied) states +6. **Return URL:** Always preserve the returnUrl in login navigation so users return to intended page after authentication + +## Common Pitfalls + +- **Wrong middleware order:** Auth middleware must come after UseAntiforgery +- **Missing CascadingAuthenticationState:** Without this, components won't receive auth state updates +- **Forgetting forceLoad:** Without it, Blazor client-side navigation bypasses server middleware +- **HTTPS requirement:** Set `RequireHttpsMetadata = false` only in development environments +- **Missing AllowAnonymous:** Don't forget to add `[AllowAnonymous]` to public pages (home, login, logout, error) or users get redirect loops +- **Poor NotAuthorized UX:** Always provide clear messaging and action buttons in the NotAuthorized template +- **Blocking async calls:** Never use `.Result` on `GetTokenAsync()` in event handlers — it can cause deadlocks and token retrieval failures in Blazor Server. Always use `async context =>` and `await` diff --git a/.ai-team/skills/blazor-oidc-redirects/SKILL.md b/.ai-team/skills/blazor-oidc-redirects/SKILL.md new file mode 100644 index 0000000..f0b094b --- /dev/null +++ b/.ai-team/skills/blazor-oidc-redirects/SKILL.md @@ -0,0 +1,178 @@ +--- +name: "blazor-oidc-redirects" +description: "Proper handling of authentication redirects in Blazor with OpenID Connect" +domain: "authentication" +confidence: "high" +source: "earned" +--- + +## Context +When implementing OpenID Connect authentication in Blazor Server applications, redirect handling must be carefully designed to: +1. Preserve the user's intended destination after login +2. Use relative paths (not absolute URIs) for returnUrl parameters +3. Prevent redirect loops by marking authentication pages as anonymous +4. Handle deep linking scenarios properly +5. **Avoid NavigationManager during component initialization** — use HttpContext.ChallengeAsync instead + +## Patterns + +### Login Page Pattern (CORRECT - Using HttpContext) +```csharp +@page "/login" +@attribute [AllowAnonymous] +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Authentication +@using Microsoft.AspNetCore.Authentication.OpenIdConnect +@inject NavigationManager Navigation +@inject IHttpContextAccessor HttpContextAccessor + +@code { + protected override async Task OnInitializedAsync() + { + var uri = new Uri(Navigation.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var returnUrl = query["returnUrl"] ?? "/"; + + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext != null) + { + var authProperties = new AuthenticationProperties + { + RedirectUri = returnUrl + }; + await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); + } + } +} +``` + +**Required Service Registration:** +```csharp +// In Program.cs +builder.Services.AddHttpContextAccessor(); +``` + +### LoginDisplay Button Handler +```csharp +private void Login() +{ + var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri); + if (string.IsNullOrEmpty(returnUrl)) + { + returnUrl = "/"; + } + Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: true); +} +``` + +### Public Page Attributes +All authentication-related pages must be marked with `[AllowAnonymous]`: +- Login page +- Logout page +- Home page (if publicly accessible) +- Error pages + +### Header Layout with AuthorizeView +Wrap header actions in FluentStack for proper spacing: +```razor + + + + + + +``` + +## Examples + +**Server-side authentication endpoint** (Program.cs): +```csharp +app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => +{ + var authProperties = new AuthenticationProperties + { + RedirectUri = returnUrl ?? "/" + }; + await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); +}); +``` + +## Anti-Patterns + +❌ **Don't use NavigationManager.NavigateTo with forceLoad during OnInitialized:** +```csharp +// WRONG - causes NavigationException in Blazor Server +protected override void OnInitialized() +{ + Navigation.NavigateTo($"/authentication/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: true); +} +``` + +✅ **Do use HttpContext.ChallengeAsync directly:** +```csharp +// CORRECT - triggers server-side authentication flow without navigation exception +protected override async Task OnInitializedAsync() +{ + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext != null) + { + await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); + } +} +``` + +❌ **Don't pass full URI as returnUrl:** +```csharp +// WRONG - passes full URI like https://localhost:5001/posts +Navigation.NavigateTo($"/authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}", forceLoad: true); +``` + +✅ **Do use relative path:** +```csharp +// CORRECT - passes relative path like /posts +var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri); +Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: true); +``` + +❌ **Don't forget [AllowAnonymous] on public pages:** +```csharp +// WRONG - causes redirect loop +@page "/login" +``` + +✅ **Do mark authentication pages as anonymous:** +```csharp +// CORRECT - allows unauthenticated access +@page "/login" +@attribute [AllowAnonymous] +``` + +❌ **Don't place header items sequentially:** +```razor + + + + ... + +``` + +✅ **Do use FluentStack with spacing:** +```razor + + + + ... + +``` + +## Why This Matters + +**NavigationException Root Cause:** +- Blazor Server uses SignalR for interactive components +- NavigationManager.NavigateTo() with forceLoad: true forces a full page reload +- During OnInitialized(), the component hasn't fully rendered yet +- Forcing a navigation before render completion causes NavigationException +- HttpContext.ChallengeAsync() triggers authentication without client-side navigation, avoiding the exception + +**Key Principle:** +Use HttpContext for server-side operations (authentication challenges) and NavigationManager only for client-side navigation after component initialization is complete. diff --git a/.ai-team/skills/resilient-ai-json-parsing/SKILL.md b/.ai-team/skills/resilient-ai-json-parsing/SKILL.md new file mode 100644 index 0000000..d51e313 --- /dev/null +++ b/.ai-team/skills/resilient-ai-json-parsing/SKILL.md @@ -0,0 +1,164 @@ +--- +name: "resilient-ai-json-parsing" +description: "Patterns for safely deserializing JSON from AI providers with unpredictable formats" +domain: "ai-integration" +confidence: "medium" +source: "earned" +--- + +## Context +AI providers (OpenAI, Claude, Reka, etc.) can return structured JSON in unpredictable formats even with schema constraints. A field specified as a string might arrive as a number, boolean, object, or array depending on the model's interpretation. Standard JSON deserializers throw exceptions on type mismatches, causing runtime failures. + +This skill applies to any codebase that deserializes JSON from AI completions, especially when using structured output or JSON schema enforcement. + +## Patterns + +### Custom JsonConverter for Flexible Fields +For fields that might vary in type across AI responses, implement a custom `JsonConverter` that handles multiple `JsonTokenType` values instead of assuming a single type. + +**Key principles:** +- Handle ALL possible token types: String, Number, Boolean, Object, Array, Null +- Use try-catch around ALL parsing logic to prevent exceptions from bubbling up +- Call `reader.Skip()` in catch blocks to avoid leaving the reader in an invalid state +- Return a sensible default (null or empty) rather than throwing +- Prefer string types for fields with variable formats (gives maximum flexibility) + +### Date Handling from AI +Dates are especially problematic because AIs might return: +- ISO strings: `"2024-01-15T10:30:00Z"` +- Simple strings: `"2024-01-15"` or `"January 15, 2024"` +- Unix timestamps: `1704067200` (number) +- Objects: `{ "year": 2024, "month": 1, "day": 15 }` +- Invalid strings: `"sometime in 2024"` +- Booleans or arrays (rare but possible) + +**Pattern:** +1. Try to parse as `DateTime` and normalize to consistent format (e.g., `yyyy-MM-dd`) +2. If parsing fails, keep the original string (preserves info for debugging) +3. For complex types (objects/arrays), skip and return null +4. For booleans/numbers, convert to string representation + +## Examples + +### C# / System.Text.Json + +```csharp +public class DateOnlyJsonConverter : JsonConverter +{ + private const string DateFormat = "yyyy-MM-dd"; + + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + + try + { + switch (reader.TokenType) + { + case JsonTokenType.String: + var dateString = reader.GetString(); + if (string.IsNullOrEmpty(dateString)) + return null; + + // Try to parse and normalize to yyyy-MM-dd + if (DateTime.TryParse(dateString, out var date)) + return date.ToString(DateFormat); + + // Keep original string if not parseable + return dateString; + + case JsonTokenType.Number: + // Handle Unix timestamp + if (reader.TryGetInt64(out var timestamp)) + { + DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var dateTime = timestamp > 2147483647 + ? epoch.AddMilliseconds(timestamp) + : epoch.AddSeconds(timestamp); + return dateTime.ToString(DateFormat); + } + break; + + case JsonTokenType.True: + case JsonTokenType.False: + // Handle unexpected boolean + return reader.GetBoolean().ToString(); + + case JsonTokenType.StartObject: + case JsonTokenType.StartArray: + // Skip complex types + reader.Skip(); + return null; + } + } + catch + { + // If parsing fails, skip the value and return null + try { reader.Skip(); } catch { /* ignore */ } + return null; + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) + { + if (value == null) + writer.WriteNullValue(); + else + writer.WriteStringValue(value); + } +} + +// Usage in domain model +public class AiResponse +{ + [JsonPropertyName("publication_date")] + [JsonConverter(typeof(DateOnlyJsonConverter))] + public string? PublicationDate { get; set; } +} +``` + +### Testing Strategy +Always test ALL edge cases, not just happy paths: + +```csharp +[Fact] +public void Read_ShouldHandleBoolean_ReturnStringRepresentation() +{ + var json = @"{ ""publication_date"": true }"; + var result = JsonSerializer.Deserialize(json); + result!.PublicationDate.Should().Be("True"); +} + +[Fact] +public void Read_ShouldHandleObject_ReturnNull() +{ + var json = @"{ ""publication_date"": { ""year"": 2024 } }"; + var result = JsonSerializer.Deserialize(json); + result!.PublicationDate.Should().BeNull(); +} + +[Fact] +public void Read_ShouldHandleInvalidString_ReturnOriginal() +{ + var json = @"{ ""publication_date"": ""sometime in 2024"" }"; + var result = JsonSerializer.Deserialize(json); + result!.PublicationDate.Should().Be("sometime in 2024"); +} +``` + +## Anti-Patterns +- **Assuming AI respects schemas** — Even with JSON schema enforcement, models can produce unexpected types +- **Throwing on parse failures** — This breaks the entire deserialization. Always catch and degrade gracefully +- **Not calling reader.Skip()** — Failing to skip invalid tokens leaves the reader in a broken state +- **Using strongly-typed dates (DateTime, DateOnly)** — These force type constraints. Use `string?` for flexibility +- **Only testing happy paths** — The whole point is handling unexpected input. Test booleans, objects, arrays, invalid formats + +## When NOT to Use +- Data from controlled sources (your own API, database) +- User input that you validate before parsing +- Internal serialization where you control both ends + +This pattern is specifically for external, unpredictable data sources like AI model outputs. diff --git a/.ai-team/skills/resilient-json-deserialization/SKILL.md b/.ai-team/skills/resilient-json-deserialization/SKILL.md new file mode 100644 index 0000000..b2e6220 --- /dev/null +++ b/.ai-team/skills/resilient-json-deserialization/SKILL.md @@ -0,0 +1,32 @@ +# Resilient JSON Deserialization for LLM Outputs + +**Confidence:** Medium +**Source:** Earned (NoteBookmark) + +When consuming JSON generated by Large Language Models (LLMs), standard strict deserialization is insufficient due to frequent format hallucinations (e.g., returning an object or array where a string is expected, or swapping date formats). + +## Pattern: The Fallback Converter + +Implement `JsonConverter` with a priority chain: + +1. **Strict Type Check:** If the token matches the target type, read it. +2. **Heuristic Conversion:** If the token is a compatible primitive (e.g., Number for Date), attempt conversion. +3. **Graceful Skip:** If the token is a complex type (Object/Array) where a primitive is expected, use `reader.Skip()` to advance the reader and return `default`/`null`. +4. **Global Catch:** Wrap the read logic in `try/catch` to return `null` rather than crashing the entire payload deserialization. + +## Example (Date Handling) + +```csharp +switch (reader.TokenType) +{ + case JsonTokenType.String: + // Try Parse -> Return formatted + // Fail Parse -> Return raw string + case JsonTokenType.Number: + // Heuristic: Int32.MaxValue separates Seconds from Milliseconds + case JsonTokenType.StartObject: + case JsonTokenType.StartArray: + reader.Skip(); // CRITICAL: Must skip to advance reader position + return null; +} +``` diff --git a/.ai-team/skills/squad-conventions/SKILL.md b/.ai-team/skills/squad-conventions/SKILL.md new file mode 100644 index 0000000..7ef2aa4 --- /dev/null +++ b/.ai-team/skills/squad-conventions/SKILL.md @@ -0,0 +1,69 @@ +--- +name: "squad-conventions" +description: "Core conventions and patterns used in the Squad codebase" +domain: "project-conventions" +confidence: "high" +source: "manual" +--- + +## Context +These conventions apply to all work on the Squad CLI tool (`create-squad`). Squad is a zero-dependency Node.js package that adds AI agent teams to any project. Understanding these patterns is essential before modifying any Squad source code. + +## Patterns + +### Zero Dependencies +Squad has zero runtime dependencies. Everything uses Node.js built-ins (`fs`, `path`, `os`, `child_process`). Do not add packages to `dependencies` in `package.json`. This is a hard constraint, not a preference. + +### Node.js Built-in Test Runner +Tests use `node:test` and `node:assert/strict` — no test frameworks. Run with `npm test`. Test files live in `test/`. The test command is `node --test test/`. + +### Error Handling — `fatal()` Pattern +All user-facing errors use the `fatal(msg)` function which prints a red `✗` prefix and exits with code 1. Never throw unhandled exceptions or print raw stack traces. The global `uncaughtException` handler calls `fatal()` as a safety net. + +### ANSI Color Constants +Colors are defined as constants at the top of `index.js`: `GREEN`, `RED`, `DIM`, `BOLD`, `RESET`. Use these constants — do not inline ANSI escape codes. + +### File Structure +- `.ai-team/` — Team state (user-owned, never overwritten by upgrades) +- `.ai-team-templates/` — Template files copied from `templates/` (Squad-owned, overwritten on upgrade) +- `.github/agents/squad.agent.md` — Coordinator prompt (Squad-owned, overwritten on upgrade) +- `templates/` — Source templates shipped with the npm package +- `.ai-team/skills/` — Team skills in SKILL.md format (user-owned) +- `.ai-team/decisions/inbox/` — Drop-box for parallel decision writes + +### Windows Compatibility +Always use `path.join()` for file paths — never hardcode `/` or `\` separators. Squad must work on Windows, macOS, and Linux. All tests must pass on all platforms. + +### Init Idempotency +The init flow uses a skip-if-exists pattern: if a file or directory already exists, skip it and report "already exists." Never overwrite user state during init. The upgrade flow overwrites only Squad-owned files. + +### Copy Pattern +`copyRecursive(src, target)` handles both files and directories. It creates parent directories with `{ recursive: true }` and uses `fs.copyFileSync` for files. + +## Examples + +```javascript +// Error handling +function fatal(msg) { + console.error(`${RED}✗${RESET} ${msg}`); + process.exit(1); +} + +// File path construction (Windows-safe) +const agentDest = path.join(dest, '.github', 'agents', 'squad.agent.md'); + +// Skip-if-exists pattern +if (!fs.existsSync(ceremoniesDest)) { + fs.copyFileSync(ceremoniesSrc, ceremoniesDest); + console.log(`${GREEN}✓${RESET} .ai-team/ceremonies.md`); +} else { + console.log(`${DIM}ceremonies.md already exists — skipping${RESET}`); +} +``` + +## Anti-Patterns +- **Adding npm dependencies** — Squad is zero-dep. Use Node.js built-ins only. +- **Hardcoded path separators** — Never use `/` or `\` directly. Always `path.join()`. +- **Overwriting user state on init** — Init skips existing files. Only upgrade overwrites Squad-owned files. +- **Raw stack traces** — All errors go through `fatal()`. Users see clean messages, not stack traces. +- **Inline ANSI codes** — Use the color constants (`GREEN`, `RED`, `DIM`, `BOLD`, `RESET`). diff --git a/.ai-team/team.md b/.ai-team/team.md new file mode 100644 index 0000000..1183347 --- /dev/null +++ b/.ai-team/team.md @@ -0,0 +1,21 @@ +# Team + +**Project:** NoteBookmark +**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework +**Owner:** fboucher (fboucher@outlook.com) + +## Project Context + +This is a Blazor-based bookmark management application with AI capabilities. Currently using custom AI services, migrating to Microsoft AI Agent Framework. + +## Roster + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| Ripley | Lead | .ai-team/agents/ripley/charter.md | ✅ Active | +| Hicks | Backend Dev | .ai-team/agents/hicks/charter.md | ✅ Active | +| Newt | Frontend Dev | .ai-team/agents/newt/charter.md | ✅ Active | +| Hudson | Tester | .ai-team/agents/hudson/charter.md | ✅ Active | +| Bishop | Code Reviewer | .ai-team/agents/bishop/charter.md | ✅ Active | +| Scribe | Session Logger | .ai-team/agents/scribe/charter.md | ✅ Active | +| Ralph | Work Monitor | — | 🔄 Monitor | From ef0b58a9a7d962011b19461d05a5458d7224c9a1 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 16 Feb 2026 16:56:30 -0500 Subject: [PATCH 07/14] Adds Keycloak authentication support Introduces Keycloak for user authentication. Provides Docker Compose deployment documentation and sample environment configuration. The AppHost now supports both development (emulator) and production (docker-compose) modes. --- .env-sample | 22 +++++ README.md | 3 +- docs/KEYCLOAK_SETUP.md | 126 ---------------------------- docs/docker-compose-deployment.md | 99 ++++++++++++++++++++++ src/NoteBookmark.AppHost/AppHost.cs | 71 ++++++++++------ 5 files changed, 170 insertions(+), 151 deletions(-) create mode 100644 .env-sample delete mode 100644 docs/KEYCLOAK_SETUP.md create mode 100644 docs/docker-compose-deployment.md diff --git a/.env-sample b/.env-sample new file mode 100644 index 0000000..3c7da79 --- /dev/null +++ b/.env-sample @@ -0,0 +1,22 @@ +# NoteBookmark Docker Compose Environment Variables +# Copy this file to .env and replace all placeholder values with your actual configuration + +# Keycloak Admin Credentials +KEYCLOAK_ADMIN_PASSWORD=your-secure-admin-password + +# Keycloak Client Configuration +KEYCLOAK_AUTHORITY=http://localhost:8080/realms/notebookmark +KEYCLOAK_CLIENT_ID=notebookmark +KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret + +# Azure Storage - Table Storage Connection +NB_STORAGE_OUTPUTS_TABLEENDPOINT=https://your-storage-account.table.core.windows.net/ + +# Azure Storage - Blob Storage Connection +NB_STORAGE_OUTPUTS_BLOBENDPOINT=https://your-storage-account.blob.core.windows.net/ + +# Notes: +# - Never commit the .env file to version control +# - Keep credentials secure and rotate them regularly +# - For local development, you can use "admin" as KEYCLOAK_ADMIN_PASSWORD +# - For production, use strong passwords and proper Azure Storage connection strings diff --git a/README.md b/README.md index 15862fa..2351ff9 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ Voila! Your app is now secure. ## Documentation For detailed setup guides and configuration information: -- [Keycloak Authentication Setup](/docs/KEYCLOAK_AUTH.md) - Complete guide for setting up Keycloak authentication +- [Keycloak Authentication Setup](/docs/keycloak_auth.md) - Complete guide for setting up Keycloak authentication +- [Docker Compose Deployment](/docs/docker-compose-deployment.md) - Deploy with Docker Compose (generate from Aspire or use provided files) ## Contributing diff --git a/docs/KEYCLOAK_SETUP.md b/docs/KEYCLOAK_SETUP.md deleted file mode 100644 index 7780d53..0000000 --- a/docs/KEYCLOAK_SETUP.md +++ /dev/null @@ -1,126 +0,0 @@ -# Keycloak Authentication Setup - -## Overview - -NoteBookmark uses Keycloak for authentication via OpenID Connect. This provides enterprise-grade identity management with support for single sign-on, user federation, and fine-grained access control. - -## Architecture - -- **AppHost**: Manages Keycloak as an Aspire resource with data persistence -- **Keycloak Container**: Runs on port 8080 with development mode enabled -- **BlazorApp**: Configured for OpenID Connect authentication pointing to Keycloak realm - -## Local Development - -### Default Credentials - -- **Admin Console**: http://localhost:8080/admin -- **Username**: `admin` -- **Password**: `admin` (or set via `KEYCLOAK_ADMIN_PASSWORD` environment variable) - -### Realm Configuration - -The application expects a realm named `notebookmark` with: -- **Client ID**: `notebookmark` -- **Client Secret**: Set via `KEYCLOAK_CLIENT_SECRET` environment variable -- **Valid Redirect URIs**: - - `https://localhost:*/signin-oidc` - - `http://localhost:*/signin-oidc` -- **Valid Post Logout Redirect URIs**: - - `https://localhost:*` - - `http://localhost:*` - -### Environment Variables - -For development, set these in `appsettings.development.json`: - -```json -{ - "Keycloak": { - "Authority": "http://localhost:8080/realms/notebookmark", - "ClientId": "notebookmark", - "ClientSecret": "YOUR_CLIENT_SECRET" - } -} -``` - -## Docker Compose - -Keycloak is defined in `docker-compose/docker-compose.yaml`: - -- **Image**: `quay.io/keycloak/keycloak:26.1` -- **Port**: 8080 -- **Data Volume**: `keycloak-data` for persistence -- **Network**: `aspire` (shared with API and BlazorApp) - -### Environment Variables for docker-compose - -Set these environment variables before running docker-compose: - -```bash -export KEYCLOAK_ADMIN_PASSWORD=your_secure_password -export KEYCLOAK_CLIENT_SECRET=your_client_secret -export KEYCLOAK_AUTHORITY=http://localhost:8080/realms/notebookmark -export KEYCLOAK_CLIENT_ID=notebookmark -``` - -## Production Considerations - -### HTTPS Requirements - -In production, you **must**: -1. Set `Keycloak:Authority` to an HTTPS URL (e.g., `https://keycloak.yourdomain.com/realms/notebookmark`) -2. Use valid SSL certificates for Keycloak -3. Ensure `RequireHttpsMetadata = true` in OpenID Connect configuration (default) - -### Secrets Management - -Never commit secrets to source control. Use: -- Azure Key Vault for production secrets -- User Secrets for local development: `dotnet user-secrets set "Keycloak:ClientSecret" "your-secret"` -- Environment variables in deployment environments - -### Keycloak Configuration - -For production: -1. Disable development mode (`start-dev` → `start`) -2. Configure proper database backend (PostgreSQL recommended) -3. Enable clustering if needed for high availability -4. Set up proper logging and monitoring -5. Configure rate limiting and security headers - -## First-Time Setup - -1. **Start Keycloak**: Run the AppHost or `docker-compose up keycloak` -2. **Access Admin Console**: Navigate to http://localhost:8080/admin -3. **Login**: Use admin/admin -4. **Create Realm**: - - Name it `notebookmark` - - Configure as needed -5. **Create Client**: - - Client ID: `notebookmark` - - Client Protocol: `openid-connect` - - Access Type: `confidential` - - Valid Redirect URIs: `https://localhost:*/signin-oidc` - - Copy the client secret from Credentials tab -6. **Update Configuration**: Add client secret to `appsettings.development.json` -7. **Create Users**: Add users in Users section of realm - -## Troubleshooting - -### "Unable to connect to Keycloak" -- Ensure Keycloak container is running: `docker ps | grep keycloak` -- Check port 8080 is not already in use -- Verify network connectivity: `curl http://localhost:8080` - -### "Invalid redirect URI" -- Check Keycloak client configuration matches your app's redirect URI -- Ensure wildcards are properly configured for development - -### "Invalid client secret" -- Verify `Keycloak:ClientSecret` matches the value in Keycloak admin console -- Check environment variables are properly set - -### "HTTPS metadata required" -- For development: Set `RequireHttpsMetadata = false` in Program.cs (already configured) -- For production: Use HTTPS Authority URL diff --git a/docs/docker-compose-deployment.md b/docs/docker-compose-deployment.md new file mode 100644 index 0000000..230a121 --- /dev/null +++ b/docs/docker-compose-deployment.md @@ -0,0 +1,99 @@ +# Docker Compose Deployment + +This guide explains how to deploy NoteBookmark using Docker Compose, either by generating it fresh from Aspire or using the provided compose file. + +## Two Deployment Options + +### Option 1: Generate from Aspire (Recommended) + +Generate an up-to-date docker-compose.yaml from your Aspire AppHost configuration. + +**Prerequisites:** +- .NET Aspire Workload installed: `dotnet workload install aspire` +- [aspirate](https://github.com/prom3theu5/aspirate) CLI tool: `dotnet tool install -g aspirate` + +**Steps:** + +1. **Generate the Aspire manifest:** + ```bash + dotnet run --project src/NoteBookmark.AppHost --publisher manifest --output-path ./aspire-manifest + ``` + This creates a `manifest.json` file that describes your application's services and dependencies. + +2. **Convert manifest to docker-compose:** + ```bash + aspirate generate --manifest-path ./aspire-manifest/manifest.json --output-path ./docker-compose-generated + ``` + This uses the `aspirate` tool to transform the Aspire manifest into a working docker-compose.yaml file. + +3. **Review the generated files:** + - `docker-compose.yaml` - Main compose file + - `.env` template - Environment variables to configure + +This ensures your docker-compose file stays in sync with the latest AppHost configuration. + +> **Note:** The `--publisher manifest` command alone does NOT generate docker-compose files - it creates an intermediate manifest.json. You need the `aspirate` tool (or similar) to convert it to docker-compose format. + +### Option 2: Use the Provided Compose File (Quick Start) + +For a quick start without cloning the repository, you can use the checked-in docker-compose.yaml file located in the `docker-compose/` directory. This is ideal if you just want to run the application quickly without generating the manifest yourself. + +## Environment Configuration + +The docker-compose.yaml file uses environment variables for configuration. You must create a `.env` file in the same directory as your docker-compose.yaml file. + +### What the .env File Is For + +The `.env` file contains sensitive configuration values needed for production deployment: + +- **Database connection strings**: Connection to Azure Table Storage and Blob Storage +- **Keycloak configuration**: Authentication server settings (authority URL, client credentials) +- **Other runtime settings**: Any environment-specific configurations + +### Creating Your .env File + +1. Copy the `.env-sample` file from the repository root: + ```bash + cp .env-sample .env + ``` + +2. Edit `.env` and replace all placeholder values with your actual configuration: + - Azure Storage connection strings + - Keycloak admin password + - Keycloak client secret + - Keycloak authority URL (if different from default) + +3. Keep `.env` secure and never commit it to version control (it's in .gitignore) + +## Running the Application + +Once your `.env` file is configured: + +```bash +cd docker-compose +docker compose up -d +``` + +Access the application at: +- **Blazor App**: http://localhost:8005 +- **API**: http://localhost:8001 +- **Keycloak**: http://localhost:8080 + +## Stopping the Application + +```bash +docker compose down +``` + +To also remove volumes (WARNING: This deletes Keycloak data): + +```bash +docker compose down -v +``` + +## Notes + +- The AppHost maintains `AddDockerComposeEnvironment("docker-env")` to integrate with the docker-compose setup +- Aspire service discovery automatically wires up connections in development +- In production (docker-compose), explicit environment variables are required +- Keycloak data persists in a named volume (`keycloak-data`) diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index d2cb411..73be74e 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -5,40 +5,63 @@ var builder = DistributedApplication.CreateBuilder(args); -#pragma warning disable ASPIRECOMPUTE001 +// Load docker-compose environment var compose = builder.AddDockerComposeEnvironment("docker-env"); // Add Keycloak authentication server var keycloak = builder.AddKeycloak("keycloak", port: 8080) .WithDataVolume(); // Persist Keycloak data across container restarts -var noteStorage = builder.AddAzureStorage("nb-storage"); - -var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); - if (builder.Environment.IsDevelopment()) { + + var noteStorage = builder.AddAzureStorage("nb-storage"); + + var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); + noteStorage.RunAsEmulator(); + + var tables = noteStorage.AddTables("nb-tables"); + var blobs = noteStorage.AddBlobs("nb-blobs"); + + var api = builder.AddProject("api") + .WithReference(tables) + .WithReference(blobs) + .WaitFor(tables) + .WaitFor(blobs); + + builder.AddProject("blazor-app") + .WithReference(api) + .WithReference(tables) // Server-side access to Azure Tables for unmasked settings + .WithReference(keycloak) // Reference Keycloak for authentication + .WaitFor(api) + .WaitFor(keycloak) + .WaitFor(compose) // Wait for docker-compose services to be ready + .WithExternalHttpEndpoints() + .WithEnvironment("REKA_API_KEY", apiKey); } +else +{ + // Production mode - no Aspire resources, expects docker-compose or Azure deployment + var noteStorage = builder.AddAzureStorage("nb-storage"); + + var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); -var tables = noteStorage.AddTables("nb-tables"); -var blobs = noteStorage.AddBlobs("nb-blobs"); - -var api = builder.AddProject("api") - .WithReference(tables) - .WithReference(blobs) - .WaitFor(tables) - .WaitFor(blobs) - .WithComputeEnvironment(compose); // comment this line to deploy to Azure - -builder.AddProject("blazor-app") - .WithReference(api) - .WithReference(tables) // Server-side access to Azure Tables for unmasked settings - .WithReference(keycloak) // Reference Keycloak for authentication - .WaitFor(api) - .WaitFor(keycloak) - .WithExternalHttpEndpoints() - .WithEnvironment("REKA_API_KEY", apiKey) - .WithComputeEnvironment(compose); // comment this line to deploy to Azure + var tables = noteStorage.AddTables("nb-tables"); + var blobs = noteStorage.AddBlobs("nb-blobs"); + + var api = builder.AddProject("api") + .WithReference(tables) + .WithReference(blobs) + .WaitFor(tables) + .WaitFor(blobs); + + builder.AddProject("blazor-app") + .WithReference(api) + .WithReference(tables) // Server-side access to Azure Tables for unmasked settings + .WaitFor(api) + .WithExternalHttpEndpoints() + .WithEnvironment("REKA_API_KEY", apiKey); +} builder.Build().Run(); From d11f6fbe62950b68e1c97874fe0e6790c3353ec5 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 16 Feb 2026 17:36:44 -0500 Subject: [PATCH 08/14] Docs: Updates Docker Compose deployment guide Updates the Docker Compose deployment documentation to reflect the Aspire CLI based deployment workflow, including environment configuration and running instructions. The documentation now describes how to generate docker-compose.yaml using Aspire instead of `aspirate`. It also configures container names in AppHost for clarity. --- docs/docker-compose-deployment.md | 91 +++++++++++++++++++++++------ src/NoteBookmark.AppHost/AppHost.cs | 24 ++++++-- 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/docs/docker-compose-deployment.md b/docs/docker-compose-deployment.md index 230a121..4a7e3fd 100644 --- a/docs/docker-compose-deployment.md +++ b/docs/docker-compose-deployment.md @@ -6,37 +6,55 @@ This guide explains how to deploy NoteBookmark using Docker Compose, either by g ### Option 1: Generate from Aspire (Recommended) -Generate an up-to-date docker-compose.yaml from your Aspire AppHost configuration. +Generate an up-to-date docker-compose.yaml from your Aspire AppHost configuration using the official Aspire CLI. **Prerequisites:** - .NET Aspire Workload installed: `dotnet workload install aspire` -- [aspirate](https://github.com/prom3theu5/aspirate) CLI tool: `dotnet tool install -g aspirate` +- Aspire CLI installed: Included with the Aspire workload **Steps:** -1. **Generate the Aspire manifest:** +1. **Publish the application (generates Docker Compose files):** ```bash - dotnet run --project src/NoteBookmark.AppHost --publisher manifest --output-path ./aspire-manifest + aspire publish ``` - This creates a `manifest.json` file that describes your application's services and dependencies. + This command generates: + - `docker-compose.yaml` from the AppHost configuration + - `.env` file template with expected parameters (unfilled) + - Output is placed in the `aspire-output` directory -2. **Convert manifest to docker-compose:** +2. **Fill in environment variables:** + Edit `aspire-output/.env` and replace placeholder values with your actual configuration: + - Azure Storage connection strings + - Keycloak admin password and client secrets + - Any other environment-specific settings + +3. **Deploy (optional - full workflow):** ```bash - aspirate generate --manifest-path ./aspire-manifest/manifest.json --output-path ./docker-compose-generated + aspire deploy ``` - This uses the `aspirate` tool to transform the Aspire manifest into a working docker-compose.yaml file. + This performs the complete workflow: publishes, prepares environment configs, builds images, and runs `docker compose up`. -3. **Review the generated files:** - - `docker-compose.yaml` - Main compose file - - `.env` template - Environment variables to configure + Or manually run Docker Compose from the output directory: + ```bash + cd aspire-output + docker compose up -d + ``` This ensures your docker-compose file stays in sync with the latest AppHost configuration. -> **Note:** The `--publisher manifest` command alone does NOT generate docker-compose files - it creates an intermediate manifest.json. You need the `aspirate` tool (or similar) to convert it to docker-compose format. +> **📚 Learn more:** See the [official Aspire Docker integration documentation](https://aspire.dev/integrations/compute/docker/) for advanced scenarios like environment-specific configs and custom image tagging. ### Option 2: Use the Provided Compose File (Quick Start) -For a quick start without cloning the repository, you can use the checked-in docker-compose.yaml file located in the `docker-compose/` directory. This is ideal if you just want to run the application quickly without generating the manifest yourself. +For a quick start, you can use the checked-in docker-compose.yaml file located in the `docker-compose/` directory. This file was generated from Aspire and committed to the repository for convenience. + +**When to use this option:** +- You want to quickly test the application without regenerating compose files +- You're deploying a stable release version +- You haven't modified the AppHost configuration + +**Important:** If you've modified `src/NoteBookmark.AppHost/AppHost.cs`, use Option 1 to regenerate the compose file to reflect your changes. ## Environment Configuration @@ -69,6 +87,13 @@ The `.env` file contains sensitive configuration values needed for production de Once your `.env` file is configured: +**If using Option 1 (Aspire-generated):** +```bash +cd aspire-output +docker compose up -d +``` + +**If using Option 2 (checked-in file):** ```bash cd docker-compose docker compose up -d @@ -79,6 +104,8 @@ Access the application at: - **API**: http://localhost:8001 - **Keycloak**: http://localhost:8080 +**First-time setup:** Keycloak needs to be configured with the realm settings. See [Keycloak Setup Guide](KEYCLOAK_SETUP.md) for detailed instructions. + ## Stopping the Application ```bash @@ -91,9 +118,39 @@ To also remove volumes (WARNING: This deletes Keycloak data): docker compose down -v ``` +## Advanced Deployment Workflows + +The Aspire CLI supports environment-specific deployments: + +**Prepare for a specific environment:** +```bash +# For staging +aspire do prepare-docker-env --environment staging + +# For production +aspire do prepare-docker-env --environment production +``` + +This generates environment-specific `.env` files and builds container images. + +**Clean up a deployment:** +```bash +aspire do docker-compose-down-docker-env +``` + +This stops and removes all containers, networks, and volumes. + ## Notes -- The AppHost maintains `AddDockerComposeEnvironment("docker-env")` to integrate with the docker-compose setup -- Aspire service discovery automatically wires up connections in development -- In production (docker-compose), explicit environment variables are required -- Keycloak data persists in a named volume (`keycloak-data`) +- **Development vs Production:** + - In development (`dotnet run`), Aspire manages Keycloak automatically via `AddKeycloak()` + - In production (docker-compose), Keycloak runs as a containerized service + - The AppHost uses `AddDockerComposeEnvironment("docker-env")` to signal Azure Container Apps deployment intent + +- **Service Discovery:** + - Development: Aspire service discovery wires up connections automatically + - Production: Services connect via explicit environment variables in `.env` + +- **Data Persistence:** + - Keycloak data persists in a named volume (`keycloak-data`) + - Use `docker compose down -v` carefully — it deletes all data including Keycloak configuration diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index 73be74e..d9453e0 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -28,7 +28,11 @@ .WithReference(tables) .WithReference(blobs) .WaitFor(tables) - .WaitFor(blobs); + .WaitFor(blobs) + .PublishAsDockerComposeService((resource, service) => + { + service.ContainerName = "notebookmark-api"; + }); builder.AddProject("blazor-app") .WithReference(api) @@ -38,7 +42,11 @@ .WaitFor(keycloak) .WaitFor(compose) // Wait for docker-compose services to be ready .WithExternalHttpEndpoints() - .WithEnvironment("REKA_API_KEY", apiKey); + .WithEnvironment("REKA_API_KEY", apiKey) + .PublishAsDockerComposeService((resource, service) => + { + service.ContainerName = "notebookmark-blazor"; + }); } else { @@ -54,14 +62,22 @@ .WithReference(tables) .WithReference(blobs) .WaitFor(tables) - .WaitFor(blobs); + .WaitFor(blobs) + .PublishAsDockerComposeService((resource, service) => + { + service.ContainerName = "notebookmark-api"; + }); builder.AddProject("blazor-app") .WithReference(api) .WithReference(tables) // Server-side access to Azure Tables for unmasked settings .WaitFor(api) .WithExternalHttpEndpoints() - .WithEnvironment("REKA_API_KEY", apiKey); + .WithEnvironment("REKA_API_KEY", apiKey) + .PublishAsDockerComposeService((resource, service) => + { + service.ContainerName = "notebookmark-blazor"; + }); } builder.Build().Run(); From 6b999e57d7b54ad80a9411e34c16fd382f2ff4bc Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Feb 2026 06:53:53 -0500 Subject: [PATCH 09/14] docs: Updates deployment instructions Updates the docker-compose deployment documentation, providing clarified build steps, parameter explanations, and configuration instructions for a smoother user experience. Removes compose wait from apphost as it doesn't work. Allows overriding RequireHttpsMetadata via configuration for development/docker scenarios --- docs/docker-compose-deployment.md | 35 +++++++++++++++++++++------ src/NoteBookmark.AppHost/AppHost.cs | 2 +- src/NoteBookmark.BlazorApp/Program.cs | 4 ++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/docker-compose-deployment.md b/docs/docker-compose-deployment.md index 4a7e3fd..8e6f354 100644 --- a/docs/docker-compose-deployment.md +++ b/docs/docker-compose-deployment.md @@ -14,24 +14,45 @@ Generate an up-to-date docker-compose.yaml from your Aspire AppHost configuratio **Steps:** -1. **Publish the application (generates Docker Compose files):** +1. **Build container images locally:** + + The generated docker-compose file references image names (e.g., `notebookmark-api`, `notebookmark-blazor`), but these images don't exist until you build them. Build and tag the images with the expected names: + ```bash - aspire publish + # Build API image + dotnet publish src/NoteBookmark.Api/NoteBookmark.Api.csproj -c Release -t:PublishContainer + + # Build Blazor app image + dotnet publish src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj -c Release -t:PublishContainer ``` + + These commands build the projects and create Docker images tagged as `notebookmark-api:latest` and `notebookmark-blazorapp:latest` (based on your project names). The container names `notebookmark-api` and `notebookmark-blazor` are what the running containers will be called. + +2. **Publish the application (generates Docker Compose files):** + ```bash + aspire publish --output-path ./aspire-output --project-name notebookmark + ``` + + **Parameters:** + - `--output-path`: Directory where docker-compose files will be generated (default: `aspire-output`) + - `--project-name`: Docker Compose project name (sets `name:` at the top of docker-compose.yaml) + - Without this, the project name defaults to the output directory name + - Affects container names: `notebookmark-api`, `notebookmark-blazor` vs `aspire-output-api`, `aspire-output-blazor` + This command generates: - `docker-compose.yaml` from the AppHost configuration - `.env` file template with expected parameters (unfilled) - - Output is placed in the `aspire-output` directory + - Supporting infrastructure files (Bicep, Azure configs if applicable) -2. **Fill in environment variables:** - Edit `aspire-output/.env` and replace placeholder values with your actual configuration: +3. **Fill in environment variables:** + Edit `./aspire-output/.env` and replace placeholder values with your actual configuration: - Azure Storage connection strings - Keycloak admin password and client secrets - Any other environment-specific settings -3. **Deploy (optional - full workflow):** +4. **Deploy (optional - full workflow):** ```bash - aspire deploy + aspire deploy --output-path ./aspire-output ``` This performs the complete workflow: publishes, prepares environment configs, builds images, and runs `docker compose up`. diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index d9453e0..264e783 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -40,7 +40,7 @@ .WithReference(keycloak) // Reference Keycloak for authentication .WaitFor(api) .WaitFor(keycloak) - .WaitFor(compose) // Wait for docker-compose services to be ready + //.WaitFor(compose) // Wait for docker-compose services to be ready .WithExternalHttpEndpoints() .WithEnvironment("REKA_API_KEY", apiKey) .PublishAsDockerComposeService((resource, service) => diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 97170f9..751cf1c 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -75,7 +75,9 @@ options.ResponseType = "code"; options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; - options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + // Allow overriding RequireHttpsMetadata via configuration for development/docker scenarios + options.RequireHttpsMetadata = builder.Configuration.GetValue("Keycloak:RequireHttpsMetadata") + ?? !builder.Environment.IsDevelopment(); options.Scope.Clear(); options.Scope.Add("openid"); From e20630674f04b27995a4df2461f160ac91e7b53d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Feb 2026 06:59:56 -0500 Subject: [PATCH 10/14] clean-up --- .ai-team/agents/bishop/charter.md | 20 - .ai-team/agents/bishop/history.md | 24 - .ai-team/agents/bishop/history_new.md | 18 - .ai-team/agents/hicks/charter.md | 19 - .ai-team/agents/hicks/history.md | 162 ------ .ai-team/agents/hudson/charter.md | 19 - .ai-team/agents/hudson/history.md | 46 -- .ai-team/agents/newt/charter.md | 19 - .ai-team/agents/newt/history.md | 121 ----- .ai-team/agents/ripley/charter.md | 18 - .ai-team/agents/ripley/history.md | 83 ---- .ai-team/agents/scribe/charter.md | 20 - .ai-team/agents/scribe/history.md | 11 - .ai-team/casting/history.json | 22 - .ai-team/casting/policy.json | 40 -- .ai-team/casting/registry.json | 46 -- .ai-team/ceremonies.md | 41 -- .ai-team/decisions.md | 86 ---- .ai-team/log/2026-02-14-ai-agent-migration.md | 42 -- .../log/2026-02-14-bishop-review-date-fix.md | 20 - .../log/2026-02-16-docker-compose-docs.md | 19 - .ai-team/log/2026-02-16-keycloak-auth.md | 21 - .ai-team/log/2026-02-16-scribe-session.md | 38 -- .ai-team/routing.md | 11 - .../aspire-keycloak-integration/SKILL.md | 463 ------------------ .../aspire-third-party-integration/SKILL.md | 140 ------ .../skills/blazor-interactive-events/SKILL.md | 117 ----- .../blazor-oidc-authentication/SKILL.md | 187 ------- .../skills/blazor-oidc-redirects/SKILL.md | 178 ------- .../skills/resilient-ai-json-parsing/SKILL.md | 164 ------- .../resilient-json-deserialization/SKILL.md | 32 -- .ai-team/skills/squad-conventions/SKILL.md | 69 --- .ai-team/team.md | 21 - 33 files changed, 2337 deletions(-) delete mode 100644 .ai-team/agents/bishop/charter.md delete mode 100644 .ai-team/agents/bishop/history.md delete mode 100644 .ai-team/agents/bishop/history_new.md delete mode 100644 .ai-team/agents/hicks/charter.md delete mode 100644 .ai-team/agents/hicks/history.md delete mode 100644 .ai-team/agents/hudson/charter.md delete mode 100644 .ai-team/agents/hudson/history.md delete mode 100644 .ai-team/agents/newt/charter.md delete mode 100644 .ai-team/agents/newt/history.md delete mode 100644 .ai-team/agents/ripley/charter.md delete mode 100644 .ai-team/agents/ripley/history.md delete mode 100644 .ai-team/agents/scribe/charter.md delete mode 100644 .ai-team/agents/scribe/history.md delete mode 100644 .ai-team/casting/history.json delete mode 100644 .ai-team/casting/policy.json delete mode 100644 .ai-team/casting/registry.json delete mode 100644 .ai-team/ceremonies.md delete mode 100644 .ai-team/decisions.md delete mode 100644 .ai-team/log/2026-02-14-ai-agent-migration.md delete mode 100644 .ai-team/log/2026-02-14-bishop-review-date-fix.md delete mode 100644 .ai-team/log/2026-02-16-docker-compose-docs.md delete mode 100644 .ai-team/log/2026-02-16-keycloak-auth.md delete mode 100644 .ai-team/log/2026-02-16-scribe-session.md delete mode 100644 .ai-team/routing.md delete mode 100644 .ai-team/skills/aspire-keycloak-integration/SKILL.md delete mode 100644 .ai-team/skills/aspire-third-party-integration/SKILL.md delete mode 100644 .ai-team/skills/blazor-interactive-events/SKILL.md delete mode 100644 .ai-team/skills/blazor-oidc-authentication/SKILL.md delete mode 100644 .ai-team/skills/blazor-oidc-redirects/SKILL.md delete mode 100644 .ai-team/skills/resilient-ai-json-parsing/SKILL.md delete mode 100644 .ai-team/skills/resilient-json-deserialization/SKILL.md delete mode 100644 .ai-team/skills/squad-conventions/SKILL.md delete mode 100644 .ai-team/team.md diff --git a/.ai-team/agents/bishop/charter.md b/.ai-team/agents/bishop/charter.md deleted file mode 100644 index 5912552..0000000 --- a/.ai-team/agents/bishop/charter.md +++ /dev/null @@ -1,20 +0,0 @@ -# Bishop — Code Reviewer - -## Role -Code reviewer and quality gatekeeper. You analyze code changes for correctness, security, maintainability, and risk. - -## Responsibilities -- Code review with focus on PROS, CONS, risks, and security -- Identify potential bugs, edge cases, and architectural concerns -- Evaluate code quality, readability, and maintainability -- Flag security vulnerabilities and performance issues -- Provide actionable, easy-to-understand feedback - -## Boundaries -- You review and provide feedback — you don't rewrite code -- Focus on substantive issues — not style nitpicks -- Approve or reject with clear rationale -- When rejecting, recommend who should handle the revision - -## Model -**Preferred:** auto (per-task) diff --git a/.ai-team/agents/bishop/history.md b/.ai-team/agents/bishop/history.md deleted file mode 100644 index 958a52f..0000000 --- a/.ai-team/agents/bishop/history.md +++ /dev/null @@ -1,24 +0,0 @@ -# Bishop's History - -## Project Learnings (from import) - -**Project:** NoteBookmark -**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework -**Owner:** fboucher (fboucher@outlook.com) - -This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. - -## Learnings - -### Learnings -- **Architecture**: The application uses a split configuration model where some settings are in Azure Table Storage (user-editable) and some are in `IConfiguration` (static/environment). -- **Risk**: The migration to Microsoft.Agents.AI introduced user-configurable AI settings, but the implementation in `ResearchService` and `SummaryService` does not consume these user-provided settings, relying instead on static configuration. -- **Pattern**: Services are injected as Transient in Blazor Server, but rely on singleton-like `IConfiguration`. -- **Anti-Pattern**: The Blazor Server app consumes the public API for its own settings. Because the API correctly masks secrets for the browser, the Server app also receives masked secrets, breaking the configuration wiring. Trusted server-side components need a privileged path to access secrets. -- **Solution (2026-02-14)**: Resolved the configuration wiring issue by introducing `AISettingsProvider`, a server-side component that reads unmasked secrets directly from Azure Table Storage. This maintains security (public API still masks keys) while allowing internal services to function correctly. This confirms the "Split Configuration Model" where trusted components use direct data access and untrusted clients use the restricted API. - -### Learnings -- **Architecture & Patterns**: We are adopting custom `JsonConverter` implementations to handle "hallucinated" or inconsistent data formats from AI services. The pattern is: `Try strict parse -> Try heuristic parse -> Fallback to raw string -> Fallback to null (safe fail)`. -- **Defensive Parsing**: For AI-generated JSON, we explicitly handle `Number`, `Boolean`, and complex token types (`StartArray`, `StartObject`) even if the schema defines a field as `string`. -- **Timestamp Heuristics**: We distinguish between Unix seconds and milliseconds using `int.MaxValue` (Year 2038 threshold) as the pivot point. -- **User Preferences**: Frank prioritizes application stability over data strictness; prefers keeping raw data if parsing fails rather than throwing exceptions. diff --git a/.ai-team/agents/bishop/history_new.md b/.ai-team/agents/bishop/history_new.md deleted file mode 100644 index 0fc088b..0000000 --- a/.ai-team/agents/bishop/history_new.md +++ /dev/null @@ -1,18 +0,0 @@ -# Bishop's History - -## Project Learnings (from import) - -**Project:** NoteBookmark -**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework -**Owner:** fboucher (fboucher@outlook.com) - -This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. - -## Learnings - -### Learnings -- **Architecture**: The application uses a split configuration model where some settings are in Azure Table Storage (user-editable) and some are in `IConfiguration` (static/environment). -- **Risk**: The migration to Microsoft.Agents.AI introduced user-configurable AI settings, but the implementation in `ResearchService` and `SummaryService` does not consume these user-provided settings, relying instead on static configuration. -- **Pattern**: Services are injected as Transient in Blazor Server, but rely on singleton-like `IConfiguration`. -- **Anti-Pattern**: The Blazor Server app consumes the public API for its own settings. Because the API correctly masks secrets for the browser, the Server app also receives masked secrets, breaking the configuration wiring. Trusted server-side components need a privileged path to access secrets. -- **Solution (2026-02-14)**: Resolved the configuration wiring issue by introducing `AISettingsProvider`, a server-side component that reads unmasked secrets directly from Azure Table Storage. This maintains security (public API still masks keys) while allowing internal services to function correctly. This confirms the "Split Configuration Model" where trusted components use direct data access and untrusted clients use the restricted API. diff --git a/.ai-team/agents/hicks/charter.md b/.ai-team/agents/hicks/charter.md deleted file mode 100644 index 9d50c3d..0000000 --- a/.ai-team/agents/hicks/charter.md +++ /dev/null @@ -1,19 +0,0 @@ -# Hicks — Backend Developer - -## Role -Backend specialist focusing on .NET services, APIs, AI integration, and server-side logic. - -## Responsibilities -- AI services implementation and migration -- .NET Core APIs and services -- Dependency injection and configuration -- Database and data access layers -- Integration with external services - -## Boundaries -- You own backend code — don't modify Blazor UI components -- Focus on functionality and correctness — let the tester validate edge cases -- Consult Ripley on architectural changes - -## Model -**Preferred:** claude-sonnet-4.5 (writing code) diff --git a/.ai-team/agents/hicks/history.md b/.ai-team/agents/hicks/history.md deleted file mode 100644 index ca690e5..0000000 --- a/.ai-team/agents/hicks/history.md +++ /dev/null @@ -1,162 +0,0 @@ -# Hicks' History - -## Project Learnings (from import) - -**Project:** NoteBookmark -**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework -**Owner:** fboucher (fboucher@outlook.com) - -This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. - -## Learnings - -### AI Services Migration to Microsoft.Agents.AI -- **File locations:** - - `src/NoteBookmark.AIServices/ResearchService.cs` - Web research with structured output - - `src/NoteBookmark.AIServices/SummaryService.cs` - Text summarization - - `Directory.Packages.props` - Central Package Management configuration - -- **Architecture patterns:** - - Use `ChatClientAgent` from Microsoft.Agents.AI as provider-agnostic wrapper - - Create `IChatClient` using OpenAI client with custom endpoint for compatibility - - Structured output via `AIJsonUtilities.CreateJsonSchema()` and `ChatResponseFormat.ForJsonSchema()` - - Configuration fallback: Settings.AiApiKey → REKA_API_KEY env var - -- **Configuration strategy:** - - Settings model already had AI configuration fields (AiApiKey, AiBaseUrl, AiModelName) - - Backward compatible with REKA_API_KEY environment variable - - Default values preserve Reka compatibility (reka-flash-3.1, reka-flash-research) - -- **DI registration:** - - Removed HttpClient dependency from AI services - - Changed from `AddHttpClient()` to `AddTransient()` in Program.cs - - Services now manage their own HTTP connections via OpenAI client - -- **Package management:** - - Project uses Central Package Management (CPM) - - Package versions go in `Directory.Packages.props`, not .csproj files - - Removed Reka.SDK dependency completely - - Added: Microsoft.Agents.AI (1.0.0-preview.260209.1), Microsoft.Extensions.AI.OpenAI (10.1.1-preview.1.25612.2) - -📌 **Team Update (2026-02-14):** Migration to Microsoft AI Agent Framework consolidated and finalized. Decision merged from Ripley (plan), Newt (settings), Hudson (tests), and Hicks (implementation) — decided by Ripley, Newt, Hudson, Hicks - -### JSON Deserialization Resilience -- **File locations:** - - `src/NoteBookmark.Domain/PostSuggestion.cs` - Domain model with custom JSON converters - - `src/NoteBookmark.Api.Tests/Domain/PostSuggestionTests.cs` - Tests for date handling resilience - -- **Pattern for handling variable AI output:** - - AI providers can return date fields in different formats (DateTime objects, Unix timestamps, ISO strings, booleans, arrays) - - Use custom `JsonConverter` to handle multiple input formats and normalize to consistent string format - - Gracefully degrade on parse failures - return null instead of throwing exceptions - - Skip unexpected complex types (objects, arrays) rather than failing - -- **DateOnlyJsonConverter implementation:** - - Handles `JsonTokenType.String` - parses any date string format and normalizes to "yyyy-MM-dd", or keeps original if not parseable - - Handles `JsonTokenType.Number` - converts Unix timestamps (both seconds and milliseconds) - - Handles `JsonTokenType.True/False` - converts boolean to string representation - - Handles `JsonTokenType.StartObject/StartArray` - skips complex types and returns null - - All parsing failures wrapped in try-catch with reader.Skip() to prevent deserialization exceptions - - Property type remains `string?` for maximum flexibility - - Comprehensive test coverage for all edge cases (booleans, numbers, objects, arrays, invalid strings) - -### Aspire Keycloak Integration for Authentication -- **File locations:** - - `src/NoteBookmark.AppHost/AppHost.cs` - Aspire AppHost with Keycloak resource - - `Directory.Packages.props` - Central package management with Keycloak hosting package - -- **Architecture pattern:** - - Use `AddKeycloak()` extension method to add Keycloak container resource to AppHost - - Keycloak runs in Docker container using `quay.io/keycloak/keycloak` image - - Default admin credentials: username=admin, password generated and stored in user secrets - - Data persistence via `WithDataVolume()` to survive container restarts - -- **Configuration:** - - Keycloak resource exposed on port 8080 (default Keycloak port) - - Both API and Blazor app reference Keycloak resource via `WithReference(keycloak)` - - WaitFor dependencies ensure Keycloak starts before dependent services - - For private website security, user management done in Keycloak admin console (create realm, configure users) - -- **Package versions:** - - Added `Aspire.Hosting.Keycloak` version `13.1.0-preview.1.25616.3` (preview version, stable 13.0.2 not yet available) - - Package follows Aspire's Central Package Management (CPM) pattern - -- **Next steps for authentication:** - - Client integration: Add `Aspire.Keycloak.Authentication` to API and Blazor projects - - Configure JWT Bearer authentication for API with `AddKeycloakJwtBearer()` - - Configure OpenId Connect authentication for Blazor with `AddKeycloakOpenIdConnect()` - - Create realm in Keycloak admin console and configure client applications - - Add user management to restrict access to selected users only - -📌 **Team Update (2026-02-16):** Keycloak Authentication Architecture consolidated. Ripley designed architecture, Hicks added Keycloak resource to AppHost, Newt implemented OpenID Connect in BlazorApp — decided by Ripley, Hicks, Newt - -### Keycloak Infrastructure Implementation (2026-02-16) - -- **AppHost Configuration:** - - Added Keycloak resource via `AddKeycloak("keycloak", port: 8080)` with data volume persistence - - BlazorApp references Keycloak via `WithReference(keycloak)` and waits for startup with `WaitFor(keycloak)` - - Service discovery automatically provides connection string to BlazorApp - -- **Docker Compose Setup:** - - Keycloak container: `quay.io/keycloak/keycloak:26.1` with `start-dev` command - - Port mapping: 8080:8080 for HTTP access in development - - Named volume `keycloak-data` persists realms, users, and configuration - - Environment variables: `KEYCLOAK_ADMIN`, `KEYCLOAK_ADMIN_PASSWORD`, HTTP-specific settings - - Network: Shares `aspire` bridge network with API and BlazorApp containers - - BlazorApp depends on both API and Keycloak services - -- **Configuration Flow:** - - AppHost Keycloak reference → Service discovery → BlazorApp environment (`services__keycloak__http__0`) - - BlazorApp reads Keycloak config from: `Keycloak:Authority`, `Keycloak:ClientId`, `Keycloak:ClientSecret` - - Docker compose supports overrides via environment variables with defaults (`${VAR:-default}`) - -- **Package Dependencies:** - - Added `Aspire.Hosting.AppHost` version 13.1.1 to Directory.Packages.props (was missing, caused build errors) - - `Aspire.Hosting.Keycloak` already present at version 13.1.1-preview.1.26105.8 - -- **Documentation:** - - Created `/docs/KEYCLOAK_SETUP.md` with setup instructions, configuration, and troubleshooting - - Covers development vs production considerations, HTTPS requirements, secrets management - -### Keycloak Logout Flow Fix (2026-02-16) - -- **Issue:** - - Keycloak logout error "Missing parameters: id_token_hint" - - OnRedirectToIdentityProviderForSignOut handler used blocking `.Result` call - - Blocking async in Blazor Server context prevented proper token retrieval - -- **Solution:** - - Changed lambda from synchronous to async: `OnRedirectToIdentityProviderForSignOut = async context =>` - - Changed token retrieval from blocking `.Result` to proper await: `var idToken = await context.HttpContext.GetTokenAsync("id_token");` - - Removed unnecessary `return Task.CompletedTask` (implicit with async lambda) - -- **Pattern for OpenID Connect event handlers:** - - Always use async lambdas when accessing async APIs like `GetTokenAsync()` - - Never use `.Result` in Blazor Server - it can cause deadlocks and context issues - - Token retrieval from HttpContext must be awaited properly in async pipeline - -### Keycloak Dual-Mode Architecture (2026-02-16) - -- **Problem:** - - Port conflict: `AddDockerComposeEnvironment()` loaded docker-compose.yaml with Keycloak on port 8080, AND `AddKeycloak()` tried to create Keycloak on same port - - Development needed Aspire-managed Keycloak, production needed standalone docker-compose orchestration - -- **Solution:** - - Removed `AddDockerComposeEnvironment()` and `.WithComputeEnvironment(compose)` calls entirely - - Split AppHost.cs into two conditional branches: `if (builder.Environment.IsDevelopment())` vs `else` - - Development: Aspire manages Keycloak via `AddKeycloak()`, runs storage emulator, full service discovery - - Production: No Keycloak reference in AppHost, docker-compose.yaml manages all containers independently - -- **Architecture pattern:** - - Development mode: AppHost orchestrates all resources (Keycloak, Storage Emulator, API, BlazorApp) - - Production mode: AppHost only defines resource references for Azure deployment, docker-compose runs actual containers - - Keycloak configured via environment variables in docker-compose for production (Authority, ClientId, ClientSecret) - - docker-compose.yaml remains unchanged - production-ready with persistent volumes and proper networking - -- **File changes:** - - `src/NoteBookmark.AppHost/AppHost.cs`: Split into dev/prod branches, removed docker-compose reference - - `docs/KEYCLOAK_SETUP.md`: Updated architecture section to explain dual-mode approach - - Build verified: Solution compiles with no errors - - -📌 Team update (2026-02-16): Keycloak Authentication & Orchestration decisions consolidated—dual-mode dev/prod architecture now in single decision block covering authentication, authorization, orchestration, and logout flow. — decided by Ripley, Hicks, Newt diff --git a/.ai-team/agents/hudson/charter.md b/.ai-team/agents/hudson/charter.md deleted file mode 100644 index 2b39b2a..0000000 --- a/.ai-team/agents/hudson/charter.md +++ /dev/null @@ -1,19 +0,0 @@ -# Hudson — Tester - -## Role -Quality assurance specialist. You write tests, verify edge cases, and ensure code correctness. - -## Responsibilities -- Unit tests and integration tests -- Test coverage analysis -- Edge case validation -- Test maintenance and refactoring -- Quality gate enforcement - -## Boundaries -- You write tests — you don't fix the code under test (report bugs to implementers) -- Focus on behavior verification, not implementation details -- Flag gaps, but let implementers decide how to fix - -## Model -**Preferred:** claude-sonnet-4.5 (writing test code) diff --git a/.ai-team/agents/hudson/history.md b/.ai-team/agents/hudson/history.md deleted file mode 100644 index e43fdf2..0000000 --- a/.ai-team/agents/hudson/history.md +++ /dev/null @@ -1,46 +0,0 @@ -# Hudson's History - -## Project Learnings (from import) - -**Project:** NoteBookmark -**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework -**Owner:** fboucher (fboucher@outlook.com) - -This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. - -## Learnings - -### Test Project Structure -- Test projects follow Central Package Management pattern (Directory.Packages.props) -- PackageReference items must not include Version attributes when CPM is enabled -- PackageVersion items in Directory.Packages.props define the versions -- Test projects use xUnit with FluentAssertions and Moq as the testing stack - -### AI Services Testing Strategy -- **File:** `src/NoteBookmark.AIServices.Tests/` - Unit test project for AI services -- **ResearchService tests:** 14 tests covering configuration, error handling, structured output -- **SummaryService tests:** 17 tests covering configuration, error handling, text generation -- Both services share identical configuration pattern: GetSettings() method with fallback hierarchy -- Configuration priority: `AppSettings:AiApiKey` → `AppSettings:REKA_API_KEY` → `REKA_API_KEY` env var -- Default baseUrl: "https://api.reka.ai/v1" -- Default models: "reka-flash-research" (Research), "reka-flash-3.1" (Summary) -- Services catch all exceptions and return safe defaults (empty PostSuggestions or empty string) -- Tests use mocked IConfiguration and ILogger - no actual API calls - -### Package Dependencies Added -- `Microsoft.Extensions.Configuration` (10.0.1) - Required for test mocks -- `Microsoft.Extensions.Logging.Abstractions` (10.0.2) - Required by Microsoft.Agents.AI dependency - -📌 **Team Update (2026-02-14):** Migration to Microsoft AI Agent Framework consolidated and finalized. Decision merged from Ripley (plan), Newt (settings), Hudson (tests), and Hicks (implementation) — decided by Ripley, Newt, Hudson, Hicks - -### Security Architecture for Settings -- **Challenge:** API endpoint masks secrets for security, but server-side Blazor app was calling that endpoint and receiving masked values, causing AI services to fail -- **Solution:** Server-side settings provider with direct Azure Table Storage access -- **File:** `src/NoteBookmark.BlazorApp/AISettingsProvider.cs` - Server-side only, bypasses HTTP API -- **Pattern:** Direct TableServiceClient access to Settings table, returns unmasked values for AI services -- **Security boundary:** API GetSettings endpoint still masks for HTTP responses; server-side DI gets unmasked values -- **Configuration:** BlazorApp now has Azure Table Storage reference in AppHost (like API project) -- **Package added to BlazorApp:** `Aspire.Azure.Data.Tables`, `Azure.Data.Tables` -- Settings provider follows same fallback hierarchy as API: Database → IConfiguration → Environment variables -- All existing tests pass (184 total: 153 API + 31 AI Services) -- Build succeeds with only pre-existing warnings (no new issues introduced) diff --git a/.ai-team/agents/newt/charter.md b/.ai-team/agents/newt/charter.md deleted file mode 100644 index 58bb529..0000000 --- a/.ai-team/agents/newt/charter.md +++ /dev/null @@ -1,19 +0,0 @@ -# Newt — Frontend Developer - -## Role -Frontend specialist focusing on Blazor UI, components, pages, and user experience. - -## Responsibilities -- Blazor components and pages -- UI/UX implementation -- Form handling and validation -- Client-side state management -- Styling and responsiveness - -## Boundaries -- You own frontend code — don't modify backend services -- Focus on user-facing features — backend logic stays in services -- Coordinate with Hicks on API contracts - -## Model -**Preferred:** claude-sonnet-4.5 (writing code) diff --git a/.ai-team/agents/newt/history.md b/.ai-team/agents/newt/history.md deleted file mode 100644 index a70d34a..0000000 --- a/.ai-team/agents/newt/history.md +++ /dev/null @@ -1,121 +0,0 @@ -# Newt's History - -## Project Learnings (from import) - -**Project:** NoteBookmark -**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework -**Owner:** fboucher (fboucher@outlook.com) - -This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. - -## Learnings - -### Settings Page Structure -- **Location:** `src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor` -- Uses FluentUI components (FluentTextField, FluentTextArea, FluentStack, etc.) -- Bound to `Domain.Settings` model via EditForm with two-way binding -- Settings are loaded via `PostNoteClient.GetSettings()` and saved via `PostNoteClient.SaveSettings()` -- Uses InteractiveServer render mode -- Follows pattern: FluentStack containers with width="100%" for form field organization - -### Domain Model Pattern -- **Location:** `src/NoteBookmark.Domain/Settings.cs` -- Implements `ITableEntity` for Azure Table Storage -- Properties decorated with `[DataMember(Name="snake_case_name")]` for serialization -- Uses nullable string properties for all user-configurable fields -- Special validation attributes like `[ContainsPlaceholder("content")]` for prompt fields - -### AI Provider Configuration Fields -- Added three new properties to Settings model: - - `AiApiKey`: Password field for sensitive API key storage - - `AiBaseUrl`: URL field for AI provider endpoint - - `AiModelName`: Text field for model identifier -- UI uses `TextFieldType.Password` for API key security -- Added visual separation with FluentDivider and section heading -- Included helpful placeholder examples in URL and model name fields - -### Keycloak/OIDC Authentication Pattern -- **Package:** `Microsoft.AspNetCore.Authentication.OpenIdConnect` (v10.0.3) -- **Configuration Location:** `appsettings.json` under `Keycloak` section (Authority, ClientId, ClientSecret) -- **Middleware Order:** Authentication → Authorization middleware must be between UseAntiforgery and MapRazorComponents -- **Authorization Setup:** - - Add `AddAuthentication()` with Cookie + OpenIdConnect schemes - - Add `AddAuthorization()` and `AddCascadingAuthenticationState()` to services - - Use `AuthorizeRouteView` instead of `RouteView` in Routes.razor - - Wrap Router in `` component -- **Page Protection:** Use `@attribute [Authorize]` on protected pages (all except Home.razor) -- **Public Pages:** Use `@attribute [AllowAnonymous]` on public pages (Home.razor, Login.razor, Logout.razor) -- **Login/Logout Flow:** - - Login: `/authentication/login` endpoint calls `ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme)` - - Logout: `/authentication/logout` endpoint signs out from both Cookie and OpenIdConnect schemes - - Login/Logout pages redirect to these endpoints with `forceLoad: true` - - **Critical:** Login page must extract returnUrl from query string and pass relative path to auth endpoint - - **Critical:** LoginDisplay must use `Navigation.ToBaseRelativePath()` to get current page as returnUrl -- **UI Pattern:** - - `LoginDisplay.razor` component uses `` to show user name + logout or login button - - Place in header layout for global visibility - - Wrap LoginDisplay and other header actions in `FluentStack` with `HorizontalGap` for proper spacing - - FluentUI icons: `Icons.Regular.Size16.Person()` for login, `Icons.Regular.Size16.ArrowExit()` for logout -- **Claims Configuration:** - - NameClaimType: "preferred_username" (Keycloak standard) - - RoleClaimType: "roles" - - Scopes: openid, profile, email - -### Blazor Interactive Components Event Handling -- **Critical:** Components with event handlers (OnClick, OnChange, etc.) require `@rendermode InteractiveServer` directive -- Without rendermode directive, click handlers and other events silently fail (no errors, just unresponsive) -- LoginDisplay component needed `@rendermode InteractiveServer` to handle Login/Logout button clicks -- Place rendermode directive at the top of the component file, before other directives -- Login.razor and Logout.razor don't need rendermode because they only execute OnInitialized lifecycle method (no user interaction) - -### Blazor Server Authentication Challenge Pattern -- **Critical:** NavigationManager.NavigateTo() with forceLoad: true during OnInitialized() causes NavigationException in Blazor Server with interactive render modes -- **Solution:** Use HttpContext.ChallengeAsync() directly instead of navigation redirect -- **Pattern:** Inject IHttpContextAccessor, extract HttpContext, call ChallengeAsync with OpenIdConnectDefaults.AuthenticationScheme -- **Required:** Add `builder.Services.AddHttpContextAccessor()` to Program.cs -- **Login.razor Pattern:** - - Use OnInitializedAsync() (async) instead of OnInitialized() (sync) - - Extract returnUrl from query string - - Create AuthenticationProperties with RedirectUri set to returnUrl - - Call httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties) -- This triggers server-side authentication flow without client-side navigation errors - -### Header Layout Positioning -- FluentHeader with FluentSpacer pushes content to the right -- Use inline `Style="margin-right: 8px;"` on FluentStack to add padding from edge of header -- Maintain HorizontalGap between adjacent items (LoginDisplay and settings icon) -- VerticalAlignment="VerticalAlignment.Center" keeps header items vertically aligned - -📌 **Team Update (2026-02-14):** Migration to Microsoft AI Agent Framework consolidated and finalized. Decision merged from Ripley (plan), Newt (settings), Hudson (tests), and Hicks (implementation) — decided by Ripley, Newt, Hudson, Hicks - -📌 **Team Update (2026-02-16):** Keycloak Authentication Architecture consolidated. Ripley designed architecture, Hicks added Keycloak resource to AppHost, Newt implemented OpenID Connect in BlazorApp — decided by Ripley, Hicks, Newt - -### Authorization Route Protection Pattern -- **Routes.razor:** Use `AuthorizeRouteView` instead of `RouteView` to enable route-level authorization -- **Cascading State:** Wrap Router in `` component -- **Page Protection:** Add `@attribute [Authorize]` to pages requiring authentication -- **Public Pages:** Add `@attribute [AllowAnonymous]` to public pages (Home, Login, Logout, Error) -- **Not Authorized UI:** AuthorizeRouteView's `` template provides custom UI for unauthorized access - - Show "Authentication Required" with Login button for unauthenticated users - - Show "Access Denied" with Home button for authenticated but unauthorized users - - Use FluentIcon for visual feedback (LockClosed for auth required, ShieldError for access denied) -- **Protected Pages:** Posts, Settings, Summaries, PostEditor, PostEditorLight, Search, SummaryEditor all require authentication -- **Public Pages:** Home (landing page), Login, Logout, Error remain accessible without authentication - -### Docker Compose Deployment Documentation -- **Location:** `/docs/docker-compose-deployment.md` -- Dual deployment strategy documented: - 1. Generate from Aspire: `dotnet run --project src/NoteBookmark.AppHost --publisher manifest --output-path ./docker-compose` - 2. Use checked-in docker-compose.yaml for quick start without repo clone -- Environment variables configured via `.env` file (never committed to git) -- `.env-sample` file provides template with placeholders for: - - Azure Storage connection strings (Table and Blob endpoints) - - Keycloak admin password - - Keycloak client credentials (authority, client ID, client secret) -- AppHost maintains `AddDockerComposeEnvironment("docker-env")` for integration -- Docker Compose file uses service dependency with `depends_on` for proper startup order -- Keycloak data persists in named volume `keycloak-data` -- README.md updated with link to docker-compose deployment documentation - - -📌 Team update (2026-02-16): Keycloak Authentication & Orchestration decisions consolidated—dual-mode dev/prod architecture now in single decision block covering authentication, authorization, orchestration, and logout flow. — decided by Ripley, Hicks, Newt diff --git a/.ai-team/agents/ripley/charter.md b/.ai-team/agents/ripley/charter.md deleted file mode 100644 index 301d283..0000000 --- a/.ai-team/agents/ripley/charter.md +++ /dev/null @@ -1,18 +0,0 @@ -# Ripley — Lead - -## Role -Lead developer and architect. You make final calls on design, coordinate the team, and review critical work. - -## Responsibilities -- Architecture decisions and design patterns -- Code review and quality gates -- Team coordination and task decomposition -- Risk assessment and technical strategy - -## Boundaries -- You review, but don't implement everything yourself — delegate to specialists -- Balance speed with quality — push back on shortcuts that create debt -- Escalate to the user when decisions need product/business input - -## Model -**Preferred:** auto (task-aware selection) diff --git a/.ai-team/agents/ripley/history.md b/.ai-team/agents/ripley/history.md deleted file mode 100644 index 3682447..0000000 --- a/.ai-team/agents/ripley/history.md +++ /dev/null @@ -1,83 +0,0 @@ -# Ripley's History - -## Project Learnings (from import) - -**Project:** NoteBookmark -**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework -**Owner:** fboucher (fboucher@outlook.com) - -This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. - -## Learnings - -### AI Services Architecture -- **Current implementation:** Uses Microsoft AI Agent Framework with provider-agnostic abstraction -- **Two services:** ResearchService (web search + structured output) and SummaryService (simple chat) -- **Configuration pattern:** Services use `Func>` provider pattern - - Primary source: User-saved settings from Azure Table Storage via API - - Fallback: IConfiguration (environment variables, appsettings.json) - - BlazorApp fetches settings via PostNoteClient.GetSettings() -- **Key files:** - - `src/NoteBookmark.AIServices/ResearchService.cs` - Handles web search with domain filtering, returns PostSuggestions - - `src/NoteBookmark.AIServices/SummaryService.cs` - Generates text summaries from content - - `src/NoteBookmark.Domain/Settings.cs` - Configuration entity (ITableEntity for Azure Table Storage) - - `src/NoteBookmark.Api/SettingEndpoints.cs` - API endpoints that mask sensitive fields (API key) - - `src/NoteBookmark.BlazorApp/Components/Pages/Settings.razor` - UI for app configuration - -### Migration to Microsoft AI Agent Framework -- **Pattern for simple chat:** Use `ChatClientAgent` with `IChatClient` from OpenAI SDK -- **Pattern for structured output:** Use `AIJsonUtilities.CreateJsonSchema()` + `ChatOptions.ResponseFormat` -- **Provider flexibility:** OpenAI client supports custom endpoints (Reka, OpenAI, Claude, Ollama) -- **Critical:** Avoid DateTime in structured output schemas - use strings for dates -- **Configuration strategy:** Add AIApiKey, AIBaseUrl, AIModelName to Settings; maintain backward compatibility with env vars - -### Security Considerations -- **API Key protection:** GetSettings endpoint masks API key with "********" to prevent client exposure -- **Storage:** API Key stored in plain text in Azure Table Storage (acceptable - protected by Azure auth) -- **SaveSettings logic:** Preserves existing API key when masked value is received -- **Trade-off:** Custom encryption not implemented due to key management complexity vs. limited benefit - -### Project Structure -- **Aspire-based:** Uses .NET Aspire orchestration (AppHost) -- **Service defaults:** Resilience policies configured via ServiceDefaults -- **Storage:** Azure Table Storage for all entities including Settings -- **UI:** FluentUI Blazor components, interactive server render mode -- **Branch strategy:** v-next is active development branch (ahead of main) - -### Dependency Injection Patterns -- **API:** IDataStorageService registered as scoped, endpoints instantiate directly with TableServiceClient/BlobServiceClient -- **BlazorApp:** AI services registered as transient with custom factory functions for settings provider -- **Settings provider:** Async function that fetches from API with fallback to IConfiguration - -📌 **Team Update (2026-02-14):** Migration to Microsoft AI Agent Framework consolidated and finalized. Decision merged from Ripley (plan), Newt (settings), Hudson (tests), and Hicks (implementation) — decided by Ripley, Newt, Hudson, Hicks - -### Authentication Architecture -- **Keycloak Integration:** Using Aspire.Hosting.Keycloak (hosting) + Aspire.Keycloak.Authentication (client) -- **Private Website Pattern:** Home page public, all other pages require authentication -- **OpenID Connect Flow:** Code flow with PKCE for Blazor interactive server -- **Realm Configuration:** JSON import at AppHost startup with pre-configured client and admin user -- **User Provisioning:** Admin-only (registration disabled) - selected users only -- **Layout Strategy:** MinimalLayout (public) vs MainLayout (authenticated with NavMenu) -- **Development vs Production:** - - Dev: `RequireHttpsMetadata = false` for local Keycloak container - - Prod: Explicit Authority URL pointing to external Keycloak instance -- **Key Files:** - - `src/NoteBookmark.AppHost/AppHost.cs` - Keycloak resource configuration - - `src/NoteBookmark.AppHost/Realms/*.json` - Realm import definitions - - `src/NoteBookmark.BlazorApp/Program.cs` - OpenID Connect registration - - `src/NoteBookmark.BlazorApp/Components/Routes.razor` - CascadingAuthenticationState - - `src/NoteBookmark.BlazorApp/Components/Layout/MinimalLayout.razor` - Public layout - -📌 **Team Update (2026-02-16):** Keycloak Authentication Architecture consolidated. Ripley designed architecture, Hicks added Keycloak resource to AppHost, Newt implemented OpenID Connect in BlazorApp — decided by Ripley, Hicks, Newt - -### Keycloak Integration Recovery (2026-07-24) -- **State of OIDC client config:** BlazorApp Program.cs has complete OpenID Connect setup (Cookie + OIDC, middleware, endpoints, cascading state). This survived intact. -- **State of auth UI:** LoginDisplay.razor, Login.razor, Logout.razor, Home.razor all exist with correct patterns (AuthorizeView, HttpContext challenge, AllowAnonymous). LoginDisplay has a bug: `forceLoad: false` needs to be `true`. -- **Missing AppHost Keycloak resource:** `Aspire.Hosting.Keycloak` NuGet is referenced in AppHost.csproj but AppHost.cs has no `AddKeycloak()` call or `WithReference(keycloak)` on projects. Container never starts. -- **Missing realm config:** `src/NoteBookmark.AppHost/Realms/` directory doesn't exist. No realm JSON for auto-provisioning. -- **Missing page authorization:** 7 pages (Posts, PostEditor, PostEditorLight, Settings, Search, Summaries, SummaryEditor) lack `@attribute [Authorize]`. Routes.razor uses `RouteView` instead of `AuthorizeRouteView`, so even if attributes were present, they wouldn't be enforced. -- **Missing _Imports.razor directives:** `@using Microsoft.AspNetCore.Authorization` and `@using Microsoft.AspNetCore.Components.Authorization` not in global imports — pages would need per-file using statements. -- **docker-compose gap:** No Keycloak service in docker-compose/docker-compose.yaml. -- **Configuration note:** `appsettings.development.json` has Keycloak config pointing to `localhost:8080`. When Aspire manages the container via `WithReference(keycloak)`, the connection string is injected automatically — hardcoded URL is redundant for Aspire but needed for non-Aspire runs. -- **API auth not in scope:** API project doesn't validate tokens. It's called server-to-server from BlazorApp. Adding API token validation is deferred. -- **PostEditorLight pattern:** Uses `@layout MinimalLayout` (no nav) but still requires authentication — minimal layout ≠ public access. diff --git a/.ai-team/agents/scribe/charter.md b/.ai-team/agents/scribe/charter.md deleted file mode 100644 index d348685..0000000 --- a/.ai-team/agents/scribe/charter.md +++ /dev/null @@ -1,20 +0,0 @@ -# Scribe — Session Logger - -## Role -Silent team member. You log sessions, merge decisions, and maintain team memory. You never speak to the user. - -## Responsibilities -- Log session activity to `.ai-team/log/` -- Merge decision inbox files into `.ai-team/decisions.md` -- Deduplicate and consolidate decisions -- Propagate team updates to agent histories -- Commit `.ai-team/` changes with proper messages -- Summarize and archive old history entries when files grow large - -## Boundaries -- Never respond to the user directly -- Never make technical decisions — only record them -- Always use file ops, never SQL (cross-platform compatibility) - -## Model -**Preferred:** claude-haiku-4.5 (mechanical file operations) diff --git a/.ai-team/agents/scribe/history.md b/.ai-team/agents/scribe/history.md deleted file mode 100644 index bc32725..0000000 --- a/.ai-team/agents/scribe/history.md +++ /dev/null @@ -1,11 +0,0 @@ -# Scribe's History - -## Project Learnings (from import) - -**Project:** NoteBookmark -**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework -**Owner:** fboucher (fboucher@outlook.com) - -This is a Blazor-based bookmark management application with AI capabilities. Currently migrating from custom AI services to Microsoft AI Agent Framework. - -## Learnings diff --git a/.ai-team/casting/history.json b/.ai-team/casting/history.json deleted file mode 100644 index f8fac63..0000000 --- a/.ai-team/casting/history.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "universe_usage_history": [ - { - "assignment_id": "notebookmark-initial", - "universe": "Alien", - "timestamp": "2026-02-14T15:02:00Z" - } - ], - "assignment_cast_snapshots": { - "notebookmark-initial": { - "universe": "Alien", - "agent_map": { - "ripley": "Ripley", - "hicks": "Hicks", - "newt": "Newt", - "hudson": "Hudson", - "scribe": "Scribe" - }, - "created_at": "2026-02-14T15:02:00Z" - } - } -} diff --git a/.ai-team/casting/policy.json b/.ai-team/casting/policy.json deleted file mode 100644 index a2faf0c..0000000 --- a/.ai-team/casting/policy.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "casting_policy_version": "1.1", - "universe": "Alien", - "allowlist_universes": [ - "The Usual Suspects", - "Reservoir Dogs", - "Alien", - "Ocean's Eleven", - "Arrested Development", - "Star Wars", - "The Matrix", - "Firefly", - "The Goonies", - "The Simpsons", - "Breaking Bad", - "Lost", - "Marvel Cinematic Universe", - "DC Universe", - "Monty Python", - "Doctor Who", - "Attack on Titan", - "The Lord of the Rings", - "Succession", - "Severance", - "Adventure Time", - "Futurama", - "Seinfeld", - "The Office", - "Cowboy Bebop", - "Fullmetal Alchemist", - "Stranger Things", - "The Expanse", - "Arcane", - "Ted Lasso", - "Dune" - ], - "universe_capacity": { - "Alien": 8 - } -} diff --git a/.ai-team/casting/registry.json b/.ai-team/casting/registry.json deleted file mode 100644 index 057f3af..0000000 --- a/.ai-team/casting/registry.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "agents": { - "ripley": { - "persistent_name": "Ripley", - "universe": "Alien", - "created_at": "2026-02-14T15:02:00Z", - "legacy_named": false, - "status": "active" - }, - "hicks": { - "persistent_name": "Hicks", - "universe": "Alien", - "created_at": "2026-02-14T15:02:00Z", - "legacy_named": false, - "status": "active" - }, - "newt": { - "persistent_name": "Newt", - "universe": "Alien", - "created_at": "2026-02-14T15:02:00Z", - "legacy_named": false, - "status": "active" - }, - "hudson": { - "persistent_name": "Hudson", - "universe": "Alien", - "created_at": "2026-02-14T15:02:00Z", - "legacy_named": false, - "status": "active" - }, - "scribe": { - "persistent_name": "Scribe", - "universe": "Alien", - "created_at": "2026-02-14T15:02:00Z", - "legacy_named": false, - "status": "active" - }, - "bishop": { - "persistent_name": "Bishop", - "universe": "Alien", - "created_at": "2026-02-14T15:24:53Z", - "legacy_named": false, - "status": "active" - } - } -} diff --git a/.ai-team/ceremonies.md b/.ai-team/ceremonies.md deleted file mode 100644 index aaa0502..0000000 --- a/.ai-team/ceremonies.md +++ /dev/null @@ -1,41 +0,0 @@ -# Ceremonies - -> Team meetings that happen before or after work. Each squad configures their own. - -## Design Review - -| Field | Value | -|-------|-------| -| **Trigger** | auto | -| **When** | before | -| **Condition** | multi-agent task involving 2+ agents modifying shared systems | -| **Facilitator** | lead | -| **Participants** | all-relevant | -| **Time budget** | focused | -| **Enabled** | ✅ yes | - -**Agenda:** -1. Review the task and requirements -2. Agree on interfaces and contracts between components -3. Identify risks and edge cases -4. Assign action items - ---- - -## Retrospective - -| Field | Value | -|-------|-------| -| **Trigger** | auto | -| **When** | after | -| **Condition** | build failure, test failure, or reviewer rejection | -| **Facilitator** | lead | -| **Participants** | all-involved | -| **Time budget** | focused | -| **Enabled** | ✅ yes | - -**Agenda:** -1. What happened? (facts only) -2. Root cause analysis -3. What should change? -4. Action items for next iteration diff --git a/.ai-team/decisions.md b/.ai-team/decisions.md deleted file mode 100644 index 8f2a122..0000000 --- a/.ai-team/decisions.md +++ /dev/null @@ -1,86 +0,0 @@ -# Decisions - -> Canonical decision ledger. All architectural, scope, and process decisions live here. - -### 2026-02-14: AI Agent Framework Migration (consolidated) - -**By:** Ripley, Hudson, Newt, Bishop - -**What:** Completed migration of NoteBookmark.AIServices from Reka SDK to Microsoft.Agents.AI provider-agnostic framework. Hudson implemented server-side AISettingsProvider to retrieve unmasked secrets from Azure Table Storage for internal services while API masks credentials for external clients. Newt enhanced DateOnlyJsonConverter for resilient date parsing across all AI provider formats. Bishop approved final implementation after security fixes. - -**Why:** -- Standardize on provider-agnostic Microsoft.Agents.AI abstraction layer -- Enable multi-provider support (OpenAI, Claude, Ollama, Reka, etc.) -- Add configurable provider settings through UI and Settings entity in Azure Table Storage -- Resolve configuration wiring: server-side services must access unmasked secrets from database while maintaining API security boundary -- Enhance resilience to AI-generated date formats (ISO8601, Unix Epoch, custom formats, unexpected types) -- Security: Prevent accidental exposure of API keys to client-side applications - -**Implementation:** Dependencies updated (Removed Reka.SDK, Added Microsoft.Agents.AI). Services refactored with ResearchService using structured JSON output and SummaryService using chat completion. Configuration via AISettingsProvider delegate with fallback hierarchy: Database → Environment Variables. API endpoints mask API keys with "********" for security. Test coverage: 31 AI service tests + 153 API tests (all passing). - -**Impact:** Multi-provider support enabled, configuration wiring works correctly, API key security maintained, AI output resilience improved. - -### 2026-02-16: Keycloak Authentication & Orchestration (consolidated) - -**By:** Ripley, Hicks, Newt - -**What:** Complete Keycloak authentication integration for NoteBookmark private website including authentication architecture, authorization enforcement, dual-mode orchestration, and logout flow. Ripley designed overall strategy (AppHost resource, BlazorApp OpenID Connect, production considerations). Hicks implemented AppHost Keycloak resource with data persistence on port 8080, realm import from ./Realms/, docker-compose service definition with persistent volume, split dev/prod modes to eliminate port conflicts, and fixed logout flow async token retrieval. Newt implemented authorization enforcement: AuthorizeRouteView in Routes.razor, [Authorize] attributes on all protected pages (Posts, Summaries, Settings, Search, Editors), [AllowAnonymous] on public pages, and fixed authentication challenge via HttpContext.ChallengeAsync() for Blazor Server compatibility. Also fixed returnUrl navigation, header layout spacing with FluentStack, and ensured all redirect pages use relative paths. - -**Why:** -- Security requirement: Convert public application to private, authenticated-only access -- User directive: Only selected users can login -- Leverage Aspire's native Keycloak integration for development container orchestration -- Use industry-standard OpenID Connect for Blazor interactive server applications -- Maintain development/production separation with explicit Authority configuration (dev: Aspire-managed, prod: docker-compose standalone) -- Eliminate port conflicts between AddDockerComposeEnvironment() and AddKeycloak() by branching on Environment.IsDevelopment() -- Enterprise-grade identity management with user administration -- Blazor Server authentication must trigger server-side via HttpContext, not client-side navigation -- Keycloak logout requires `id_token_hint` parameter which demands async/await pattern in Blazor Server context -- Route-level authorization prevents unauthorized access to all non-home pages - -**Architecture:** -- **AppHost (Development):** `AddKeycloak("keycloak", 8080).WithDataVolume()` resource, BlazorApp references keycloak with WaitFor, realm import from ./Realms/notebookmark-realm.json, branches on Environment.IsDevelopment() -- **AppHost (Production):** No Keycloak resource; expects docker-compose to manage all containers independently -- **docker-compose:** Keycloak 26.1 service on port 8080, quay.io/keycloak/keycloak image, start-dev mode, admin credentials via environment variables, named volume for data persistence -- **BlazorApp:** OpenID Connect authentication with Cookie scheme, AddCascadingAuthenticationState, AddHttpContextAccessor for challenge flow, UseAuthentication/UseAuthorization middleware -- **Authorization:** Routes.razor uses AuthorizeRouteView with CascadingAuthenticationState, Home/Login/Logout pages marked [AllowAnonymous], all other pages require [Authorize] -- **UI:** LoginDisplay component in MainLayout header using FluentStack for proper spacing, Login.razor uses HttpContext.ChallengeAsync() with query string returnUrl, Logout.razor triggers sign-out challenge with async token retrieval -- **Configuration:** Keycloak settings (Authority, ClientId, ClientSecret) injected via Aspire service discovery in development, explicit appsettings.json values for production -- **Logout Flow:** OnRedirectToIdentityProviderForSignOut event handler uses async/await for GetTokenAsync("id_token"), properly passes id_token_hint to Keycloak for clean session termination - -**Implementation Status:** -- AppHost build succeeded, docker-compose validated -- All protected pages secured with [Authorize] -- AuthorizeRouteView routing enforcement active -- HttpContext.ChallengeAsync() pattern working without NavigationException -- Login/logout flow properly handles return URLs and id_token_hint parameter -- Headers use FluentStack to prevent component overlap -- Dual-mode architecture eliminates port conflicts, clarifies dev vs prod separation - -**Next Steps:** Create Keycloak realm "notebookmark" with client configuration, configure admin user, test full authentication flow end-to-end. - -### 2026-02-14: Code Review — Bishop Oversight Standard - -**By:** Frank, Bishop - -**What:** Established that Bishop reviews all code changes going forward as part of standard quality assurance process. - -**Why:** User directive — ensure code quality and architectural consistency across team. - -### 2026-02-14: Resilient Date Parsing - -**By:** Bishop - -**What:** Enhanced `DateOnlyJsonConverter` to handle all possible JSON types that AI providers might return: strings (ISO dates, custom formats), numbers (Unix timestamps), booleans, objects, and arrays. Gracefully handles any JsonTokenType, normalizes parseable dates to "yyyy-MM-dd", preserves unparseable strings as-is, falls back to null for complex types. - -**Why:** AI models frequently hallucinate data formats or return unexpected types (null, boolean). User reported JsonException when AI returned unexpected type. Best-effort parsing allows application to function with partial data. - -### 2026-02-14: Settings UI and Database Configuration - -**By:** Bishop - -**What:** Identified disconnect between UI Settings form (saves to Azure Table Storage) and AI Service configuration (reads from IConfiguration/environment variables). No mechanism to bridge database settings to IConfiguration used by services. - -**Why:** Configuration changes in UI do not apply to AI services without environment variable updates from database (not implemented). - -**Resolution:** Hudson implemented AISettingsProvider that reads directly from Azure Table Storage, creating proper bridge between UI and services while maintaining API security boundary. diff --git a/.ai-team/log/2026-02-14-ai-agent-migration.md b/.ai-team/log/2026-02-14-ai-agent-migration.md deleted file mode 100644 index 327aec6..0000000 --- a/.ai-team/log/2026-02-14-ai-agent-migration.md +++ /dev/null @@ -1,42 +0,0 @@ -# Session Log: 2026-02-14 AI Agent Migration - -**Requested by:** fboucher - -## Summary - -Scribe processed AI team decisions and consolidated session artifacts. - -## Activities - -**Inbox Merged (4 files):** -- Hicks: Completed migration to Microsoft AI Agent Framework -- Hudson: Test coverage for AI services (31 unit tests) -- Newt: AI provider configuration in Settings -- Ripley: Migration plan and framework analysis - -**Consolidation:** -- Identified 4 overlapping decisions covering the same AI services migration initiative -- Synthesized single consolidated decision block: "2026-02-14: Migration to Microsoft AI Agent Framework (consolidated)" -- Merged rationale from all authors; preserved implementation details from Hicks, test coverage from Hudson, settings design from Newt, and technical analysis from Ripley - -**Decisions Written:** -- .ai-team/decisions.md updated with consolidated decision record - -**Files Deleted:** -- .ai-team/decisions/inbox/hicks-ai-agent-migration-complete.md -- .ai-team/decisions/inbox/hudson-ai-services-test-coverage.md -- .ai-team/decisions/inbox/newt-ai-provider-settings.md -- .ai-team/decisions/inbox/ripley-ai-agent-migration.md - -## Decision Summary - -**Consolidation:** Migration to Microsoft AI Agent Framework -- From Reka SDK to Microsoft.Agents.AI (provider-agnostic) -- Includes configurable settings, comprehensive test coverage -- Backward compatible; web search domain filtering removed -- Status: Implementation complete - -## Next Steps - -- Agents affected by this decision will receive history notifications -- Session ready for git commit diff --git a/.ai-team/log/2026-02-14-bishop-review-date-fix.md b/.ai-team/log/2026-02-14-bishop-review-date-fix.md deleted file mode 100644 index 989d265..0000000 --- a/.ai-team/log/2026-02-14-bishop-review-date-fix.md +++ /dev/null @@ -1,20 +0,0 @@ -# Session Log: Bishop Review — Date Parsing Fix - -**Date:** 2026-02-14 -**Requested by:** frank -**Participants:** Bishop, Hicks -**Session Type:** Code Review - -## Summary -Bishop reviewed Hicks's defensive date parsing implementation for JSON deserialization. Enhanced `DateOnlyJsonConverter` to handle all possible JSON types (strings, numbers, booleans, objects, arrays) that AI providers might return. - -## Outcome -✅ **Approved** — The defensive date parsing strategy is sound. Graceful handling of unpredictable AI output formats prevents service failures. - -## Directive Captured -Bishop will review all code changes going forward (user directive: "yes, always"). - -## Impact -- Resilient JSON deserialization for AI-generated date fields -- Eliminates `JsonException` failures on unexpected type conversions -- Maintains backward compatibility with expected formats diff --git a/.ai-team/log/2026-02-16-docker-compose-docs.md b/.ai-team/log/2026-02-16-docker-compose-docs.md deleted file mode 100644 index 546f1c2..0000000 --- a/.ai-team/log/2026-02-16-docker-compose-docs.md +++ /dev/null @@ -1,19 +0,0 @@ -# Session: Docker-Compose Deployment Documentation - -**Requested by:** fboucher - -## Summary - -User changed direction mid-session: initially planned to remove AddDockerComposeEnvironment from AppHost, but changed course to keep it and create documentation instead. Final decision was to implement dual-mode architecture—development uses Aspire's native Keycloak, production uses docker-compose standalone. - -## Work Completed - -1. **Hicks:** Removed AddDockerComposeEnvironment() from AppHost to resolve port conflicts. Split Keycloak into dev/prod modes: development uses Aspire-managed lifecycle, production expects docker-compose to manage containers independently. - -2. **Hicks:** Fixed Keycloak logout flow by converting OnRedirectToIdentityProviderForSignOut event handler to async and properly awaiting GetTokenAsync("id_token") call—resolves "Missing parameters: id_token_hint" error. - -## Decisions Made - -- Keep AddDockerComposeEnvironment in docker-compose.yaml; document it for production users instead of removing it -- Implement dual-mode: AppHost branches on Environment.IsDevelopment() for Keycloak configuration -- Production deployment uses docker-compose.yaml independently without AppHost interference diff --git a/.ai-team/log/2026-02-16-keycloak-auth.md b/.ai-team/log/2026-02-16-keycloak-auth.md deleted file mode 100644 index bbadb64..0000000 --- a/.ai-team/log/2026-02-16-keycloak-auth.md +++ /dev/null @@ -1,21 +0,0 @@ -# Session Log — 2026-02-16 - -**Requested by:** fboucher - -## Team Activity - -**Ripley:** Designed Keycloak authentication architecture for private website access. Defined AppHost layer (Keycloak resource, realm configuration), BlazorApp layer (OpenID Connect), and production deployment considerations. - -**Hicks:** Added Keycloak container resource to Aspire AppHost with data persistence. Configured API and Blazor app references. Using Aspire.Hosting.Keycloak v13.1.0-preview. - -**Newt:** Implemented OpenID Connect authentication guards in Blazor app. Added LoginDisplay component, protected pages with @Authorize attribute, configured cascading authentication state and OIDC middleware. Only home page remains public. - -**Hudson:** Implemented server-side AISettingsProvider to retrieve unmasked AI configuration from Azure Table Storage, bypassing the HTTP API's client-facing masking. Ensures AI services receive real credentials from user settings. - -**Bishop:** Completed final review of AI Agent Framework migration. Approved Hudson's fix for configuration wiring. All 184 tests passing. Migration ready for deployment. - -## Decisions Merged - -- Merged 11 decision files from inbox into decisions.md -- Consolidated overlapping decisions on Keycloak architecture, authentication, and AI services configuration -- Deduplicating exact matches and synthesizing overlapping blocks diff --git a/.ai-team/log/2026-02-16-scribe-session.md b/.ai-team/log/2026-02-16-scribe-session.md deleted file mode 100644 index faf6b13..0000000 --- a/.ai-team/log/2026-02-16-scribe-session.md +++ /dev/null @@ -1,38 +0,0 @@ -# Session Log — 2026-02-16 - -**Requested by:** fboucher - -## What Happened - -1. **Merged 5 decision inbox files** into decisions.md: - - hicks-keycloak-apphost-implementation.md - - newt-authorization-protection.md - - newt-blazor-auth-challenge-pattern.md - - newt-keycloak-auth-fixes.md - - ripley-keycloak-integration-strategy.md - -2. **Consolidated overlapping decisions:** - - Identified that all 5 inbox files relate to the Keycloak authentication architecture already consolidated on 2026-02-16 - - Merged new details from Hicks, Newt, and Ripley into enhanced "2026-02-16: Keycloak Authentication Architecture" block - - Removed Ripley's strategy document (superseded by implementation records from Hicks/Newt) - -3. **Updated decisions.md** with merged content from inbox, removed exact duplicate entries - -## Key Decisions Recorded - -- **Keycloak AppHost implementation:** Hicks added Keycloak container resource with data volume, proper service discovery, and docker-compose configuration -- **Authorization protection:** Newt implemented AuthorizeRouteView with [Authorize] attributes across protected pages -- **Blazor auth challenge pattern:** Newt switched from NavigationManager.NavigateTo() to HttpContext.ChallengeAsync() for Blazor Server compatibility -- **Keycloak bug fixes:** Newt fixed returnUrl navigation, layout spacing, and AllowAnonymous attributes -- **Integration strategy:** Ripley provided overall architecture and gap analysis for complete authentication restoration - -## Files Modified - -- `.ai-team/log/2026-02-16-scribe-session.md` — created -- `.ai-team/decisions.md` — merged 5 inbox decisions, consolidated overlapping blocks -- `.ai-team/decisions/inbox/*` — 5 files deleted after merge - -## No Further Actions - -- No agent history updates required (decisions are team-wide) -- No history.md archival needed (all within size bounds) diff --git a/.ai-team/routing.md b/.ai-team/routing.md deleted file mode 100644 index 71bb612..0000000 --- a/.ai-team/routing.md +++ /dev/null @@ -1,11 +0,0 @@ -# Routing - -| Signal | Agent | Examples | -|--------|-------|----------| -| Architecture, design decisions, coordination | Ripley | "Design the auth flow", "Review architecture" | -| Backend, AI services, .NET core, APIs, C# backend | Hicks | "Migrate AI services", "Add API endpoint", "Configure DI" | -| Frontend, Blazor, UI components, pages, forms | Newt | "Build settings page", "Update UI", "Add form validation" | -| Tests, quality, edge cases, validation | Hudson | "Write tests", "Test coverage", "Verify edge cases" | -| Code review, security review, quality gates | Bishop | "Review this code", "Check for security issues", "Review the changes" | -| Session logging, decisions, memory (silent) | Scribe | (auto-triggered after agent work) | -| Work queue, backlog monitoring | Ralph | "Ralph, go", "Keep working", "Work until done" | diff --git a/.ai-team/skills/aspire-keycloak-integration/SKILL.md b/.ai-team/skills/aspire-keycloak-integration/SKILL.md deleted file mode 100644 index 302ce96..0000000 --- a/.ai-team/skills/aspire-keycloak-integration/SKILL.md +++ /dev/null @@ -1,463 +0,0 @@ ---- -name: "aspire-keycloak-integration" -description: "Integrate Keycloak authentication with Aspire-hosted applications using OpenID Connect" -domain: "security, authentication, aspire" -confidence: "high" -source: "earned" ---- - -## Context - -When building Aspire applications that require authentication, Keycloak provides an open-source Identity and Access Management solution. Aspire has first-class support for Keycloak through hosting and client integrations. - -Use this pattern when: -- Building private/authenticated applications with Aspire -- Need to control user access (admin-managed users) -- Want containerized local development with production-ready auth -- Require OpenID Connect for web applications - -## Patterns - -### AppHost Configuration (Hosting Integration) - -1. **Add NuGet Package:** `Aspire.Hosting.Keycloak` to AppHost project - -2. **Basic Keycloak Resource:** -```csharp -var builder = DistributedApplication.CreateBuilder(args); - -var keycloak = builder.AddKeycloak("keycloak", 8080); - -var blazorApp = builder.AddProject("blazor-app") - .WithReference(keycloak) - .WaitFor(keycloak); -``` - -3. **With Realm Import (Recommended):** -```csharp -var keycloak = builder.AddKeycloak("keycloak", 8080) - .WithRealmImport("./Realms"); // Import realm JSON files on startup -``` - -4. **With Data Persistence:** -```csharp -var keycloak = builder.AddKeycloak("keycloak", 8080) - .WithDataVolume() // Persist data across container restarts - .WithRealmImport("./Realms"); -``` - -5. **With Custom Admin Credentials:** -```csharp -var username = builder.AddParameter("keycloak-admin"); -var password = builder.AddParameter("keycloak-password", secret: true); - -var keycloak = builder.AddKeycloak("keycloak", 8080, username, password); -``` - -### Blazor App Configuration (Client Integration) - -1. **Add NuGet Package:** `Aspire.Keycloak.Authentication` to Blazor project - -2. **Register OpenID Connect Authentication (Program.cs):** -```csharp -builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) - .AddKeycloakOpenIdConnect( - serviceName: "keycloak", // Must match AppHost resource name - realm: "my-realm", - options => - { - options.ClientId = "my-blazor-app"; - options.ResponseType = OpenIdConnectResponseType.Code; - options.Scope.Add("profile"); - - // Development only - disable HTTPS validation - if (builder.Environment.IsDevelopment()) - { - options.RequireHttpsMetadata = false; - } - }); - -// Add authentication services -builder.Services.AddAuthorization(); -builder.Services.AddCascadingAuthenticationState(); -``` - -3. **Add Middleware (after UseRouting, before UseAntiforgery):** -```csharp -app.UseAuthentication(); -app.UseAuthorization(); -``` - -4. **Wrap Router with Authentication State (Routes.razor or App.razor):** -```razor - - - - - - - - - - - -``` - -### Realm Configuration (JSON Import) - -**File:** `src/AppHost/Realms/my-realm.json` - -```json -{ - "realm": "my-realm", - "enabled": true, - "sslRequired": "external", - "registrationAllowed": false, - "clients": [ - { - "clientId": "my-blazor-app", - "protocol": "openid-connect", - "publicClient": true, - "redirectUris": [ - "http://localhost:*/signin-oidc", - "https://*.azurewebsites.net/signin-oidc" - ], - "webOrigins": ["+"], - "standardFlowEnabled": true, - "directAccessGrantsEnabled": false - } - ], - "users": [ - { - "username": "admin", - "enabled": true, - "credentials": [ - { - "type": "password", - "value": "admin123", - "temporary": false - } - ] - } - ] -} -``` - -**Key Settings:** -- `registrationAllowed: false` - For private applications (admin creates users) -- `publicClient: true` - For SPAs/Blazor (no client secret needed in browser) -- `redirectUris` - Wildcard patterns for dev + production URLs -- `webOrigins: ["+"]` - Allow same-origin requests - -### Production Configuration - -**Development (local container):** -```csharp -if (builder.Environment.IsDevelopment()) -{ - options.RequireHttpsMetadata = false; -} -``` - -**Production (external Keycloak):** -```csharp -if (!builder.Environment.IsDevelopment()) -{ - options.Authority = "https://keycloak.mydomain.com/realms/my-realm"; - // RequireHttpsMetadata defaults to true -} -``` - -**AppHost connection string for production:** -```csharp -builder.AddConnectionString("keycloak", "https://keycloak.mydomain.com"); -``` - -## Examples - -### Mixed Public/Private Pages - -**Public Home Page:** -```razor -@page "/" -@layout MinimalLayout - -

Welcome

-

Sign in to continue

-``` - -**Protected Page:** -```razor -@page "/dashboard" -@attribute [Authorize] - -

Dashboard

- - -

Hello, @context.User.Identity.Name!

-
-
-``` - -**Conditional Navigation (NavMenu.razor):** -```razor - - - Dashboard - Settings - - - Sign In - - -``` - -### Login/Logout Buttons - -```razor -@inject NavigationManager Navigation - - - - Logout - - - Login - - - -@code { - private void LoginAsync() - { - Navigation.NavigateTo("/login", forceLoad: true); - } - - private void LogoutAsync() - { - Navigation.NavigateTo("/logout", forceLoad: true); - } -} -``` - -## Anti-Patterns - -### ❌ Don't: Use HTTP in production -```csharp -// NEVER do this in production -options.RequireHttpsMetadata = false; -``` - -### ❌ Don't: Store client secrets in code -```csharp -// Bad - secret in code -options.ClientSecret = "my-secret-key"; - -// Good - use parameter or Key Vault -var clientSecret = builder.AddParameter("keycloak-client-secret", secret: true); -``` - -### ❌ Don't: Enable public registration for private apps -```json -// Bad for private applications -{ - "realm": "my-realm", - "registrationAllowed": true // Anyone can register! -} -``` - -### ❌ Don't: Forget WaitFor dependency -```csharp -// Bad - app might start before Keycloak ready -var blazorApp = builder.AddProject("blazor-app") - .WithReference(keycloak); // Missing .WaitFor(keycloak) -``` - -### ✅ Do: Use explicit Authority in production -```csharp -// Good - explicit configuration -if (!builder.Environment.IsDevelopment()) -{ - options.Authority = builder.Configuration["Keycloak:Authority"]; -} -``` - -### ✅ Do: Persist Keycloak data in development -```csharp -// Good - preserve realm config across restarts -var keycloak = builder.AddKeycloak("keycloak", 8080) - .WithDataVolume(); -``` - -### ✅ Do: Use realm import for consistent setup -```csharp -// Good - version-controlled realm configuration -var keycloak = builder.AddKeycloak("keycloak", 8080) - .WithRealmImport("./Realms"); -``` - -### ✅ Do: Use confidential client for server-side Blazor -Server-rendered Blazor apps can safely hold a client secret. Use confidential (non-public) client type for stronger security than `publicClient: true`. - -### ✅ Do: Verify the full auth chain -Three things must all be present for Keycloak auth to work: -1. **AppHost resource** — `AddKeycloak()` + `WithReference()` + `WaitFor()` on dependent projects -2. **Routes enforcement** — `AuthorizeRouteView` in Routes.razor (not plain `RouteView`) -3. **Page attributes** — `@attribute [Authorize]` on every non-public page - -Missing any one of these silently degrades to unauthenticated access. - -## Docker Compose Integration Pattern - -When using both Aspire and docker-compose deployment (dual orchestration): - -### 1. AppHost Declaration - -```csharp -var keycloak = builder.AddKeycloak("keycloak", port: 8080) - .WithDataVolume(); - -builder.AddProject("blazor-app") - .WithReference(keycloak) - .WaitFor(keycloak) - .WithComputeEnvironment(compose); // docker-compose deployment -``` - -### 2. Docker Compose Service - -```yaml -services: - keycloak: - image: "quay.io/keycloak/keycloak:26.1" - container_name: "app-keycloak" - command: ["start-dev"] - environment: - KEYCLOAK_ADMIN: "admin" - KEYCLOAK_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD:-admin}" - KC_HTTP_PORT: "8080" - KC_HOSTNAME_STRICT: "false" # Dev only - KC_HOSTNAME_STRICT_HTTPS: "false" # Dev only - KC_HTTP_ENABLED: "true" # Dev only - ports: - - "8080:8080" - volumes: - - keycloak-data:/opt/keycloak/data - networks: - - "aspire" - - blazor-app: - depends_on: - keycloak: - condition: "service_started" - environment: - services__keycloak__http__0: "http://keycloak:8080" - Keycloak__Authority: "${KEYCLOAK_AUTHORITY:-http://localhost:8080/realms/my-realm}" - Keycloak__ClientId: "${KEYCLOAK_CLIENT_ID:-my-client}" - Keycloak__ClientSecret: "${KEYCLOAK_CLIENT_SECRET}" - -volumes: - keycloak-data: - driver: "local" -``` - -### 3. Environment Variable Defaults - -Use `${VAR:-default}` syntax for optional variables with fallback: -- `${KEYCLOAK_ADMIN_PASSWORD:-admin}` — defaults to "admin" if not set -- `${KEYCLOAK_AUTHORITY:-http://localhost:8080/realms/my-realm}` — dev default - -### 4. Service Discovery Mapping - -Aspire service references translate to docker-compose environment variables: -- AppHost: `.WithReference(keycloak)` -- docker-compose: `services__keycloak__http__0: "http://keycloak:8080"` - -This enables service-to-service communication within the docker network. - -## Dual-Mode Pattern: Development vs Production - -**Problem:** Port conflicts when both AppHost and docker-compose try to manage Keycloak on same port. - -**Solution:** Conditional resource configuration based on environment: - -### Development Mode (Aspire-managed) - -```csharp -var builder = DistributedApplication.CreateBuilder(args); - -if (builder.Environment.IsDevelopment()) -{ - var keycloak = builder.AddKeycloak("keycloak", port: 8080) - .WithDataVolume(); - - var noteStorage = builder.AddAzureStorage("storage") - .RunAsEmulator(); - - var api = builder.AddProject("api") - .WithReference(noteStorage); - - builder.AddProject("blazor-app") - .WithReference(api) - .WithReference(keycloak) // Aspire manages Keycloak - .WaitFor(keycloak); -} -``` - -**Benefits:** -- Aspire automatically starts/stops Keycloak -- Service discovery works automatically -- Storage emulator for local development -- Full integration with AppHost dashboard - -### Production Mode (docker-compose standalone) - -```csharp -else -{ - // No Keycloak resource - docker-compose manages it - var noteStorage = builder.AddAzureStorage("storage"); - - var api = builder.AddProject("api") - .WithReference(noteStorage); - - builder.AddProject("blazor-app") - .WithReference(api); - // No Keycloak reference - uses environment variables from docker-compose -} -``` - -**Benefits:** -- No port conflicts between AppHost and docker-compose -- docker-compose.yaml runs independently -- BlazorApp reads Keycloak config from environment variables -- Supports Azure deployment without code changes - -### Configuration Flow - -**Development:** -1. Run AppHost → Aspire starts Keycloak container -2. Service discovery injects Keycloak connection to BlazorApp -3. BlazorApp connects to `http://localhost:8080` - -**Production:** -1. Run `docker-compose up` → Standalone Keycloak container starts -2. BlazorApp reads `Keycloak:Authority`, `Keycloak:ClientId` from environment -3. BlazorApp connects to Keycloak via docker network or external URL - -### Key Points - -✅ **Do:** Split AppHost into dev/prod branches when orchestration differs -✅ **Do:** Keep docker-compose.yaml production-ready (works standalone) -✅ **Do:** Use environment variables in docker-compose for configuration -✅ **Don't:** Try to use both AppHost Keycloak and docker-compose Keycloak simultaneously - -## Implementation Updated (2026-02-16) - -Added comprehensive docker-compose integration pattern with: -- Keycloak 26.1 container configuration (latest stable) -- Environment variable defaults and overrides -- Volume persistence setup -- Service dependency orchestration -- Configuration flow from AppHost → docker-compose → application -- **NEW:** Dual-mode pattern for dev (Aspire) vs prod (docker-compose) orchestration separation - -**Testing:** Validated with `docker-compose config --quiet` (passed). diff --git a/.ai-team/skills/aspire-third-party-integration/SKILL.md b/.ai-team/skills/aspire-third-party-integration/SKILL.md deleted file mode 100644 index cff7f64..0000000 --- a/.ai-team/skills/aspire-third-party-integration/SKILL.md +++ /dev/null @@ -1,140 +0,0 @@ ---- -name: "aspire-third-party-integration" -description: "Patterns for integrating third-party services (databases, auth, messaging) into .NET Aspire AppHost" -domain: "aspire-hosting" -confidence: "low" -source: "earned" ---- - -## Context -.NET Aspire provides hosting integrations for third-party services through NuGet packages (e.g., Aspire.Hosting.PostgreSQL, Aspire.Hosting.RabbitMQ, Aspire.Hosting.Keycloak). These packages allow you to add containerized or cloud-based services to your AppHost and reference them from your application projects. - -This skill applies when: -- Adding a new external service to an Aspire application -- Following Aspire's resource orchestration patterns -- Integrating authentication, databases, messaging, or storage services - -## Patterns - -### Package Installation Pattern (Central Package Management) -When the project uses Central Package Management (CPM): - -1. **Add version to Directory.Packages.props** - ```xml - - ``` - -2. **Add PackageReference to AppHost.csproj** (version-less) - ```xml - - ``` - -3. **Handle preview versions**: Some Aspire integrations may only have preview versions available. Use the latest preview if stable version doesn't exist (e.g., `13.1.0-preview.1.25616.3`). - -### Resource Declaration Pattern -In AppHost.cs (or AppHost Program.cs): - -```csharp -var builder = DistributedApplication.CreateBuilder(args); - -// 1. Declare the resource with configuration -var resourceName = builder.AddServiceName("resource-name", port) - .WithDataVolume() // Optional: persist data - .WithDataBindMount(path) // Alternative: bind mount for data - .WithOtlpExporter(); // Optional: enable telemetry - -// 2. Reference the resource from dependent projects -var api = builder.AddProject("api") - .WithReference(resourceName) // Injects connection string as env var - .WaitFor(resourceName); // Ensures startup order - -var web = builder.AddProject("web") - .WithReference(resourceName) - .WaitFor(resourceName); - -builder.Build().Run(); -``` - -### Data Persistence Options -Choose based on requirements: - -- **No persistence**: Default behavior, data lost on container restart -- **WithDataVolume()**: Docker volume managed by Aspire, survives restarts -- **WithDataBindMount(path)**: Specific host path for data, useful for backups/migration - -### Resource Ordering with WaitFor() -Critical for dependency chains: -```csharp -.WaitFor(storage) // Wait for storage before starting -.WaitFor(database) // Can chain multiple dependencies -``` - -### Authentication/Security Resources -For services like Keycloak, Auth0, etc.: - -1. Default credentials generated and stored in user secrets: - ```json - { - "Parameters:resource-name-password": "GENERATED_PASSWORD" - } - ``` - -2. Access admin console using credentials from secrets -3. Configure realms, clients, users in service admin UI -4. Client projects add authentication packages separately - -## Examples - -### Keycloak Integration -```csharp -// AppHost.cs -var keycloak = builder.AddKeycloak("keycloak", 8080) - .WithDataVolume(); - -var api = builder.AddProject("api") - .WithReference(keycloak) - .WaitFor(keycloak); - -var web = builder.AddProject("web") - .WithReference(keycloak) - .WaitFor(keycloak); -``` - -### PostgreSQL with Volume -```csharp -var postgres = builder.AddPostgres("postgres", 5432) - .WithDataVolume() - .AddDatabase("mydb"); - -var api = builder.AddProject("api") - .WithReference(postgres) - .WaitFor(postgres); -``` - -### RabbitMQ with Telemetry -```csharp -var messaging = builder.AddRabbitMQ("messaging", 5672) - .WithDataVolume() - .WithOtlpExporter(); // Export metrics to Aspire dashboard - -var worker = builder.AddProject("worker") - .WithReference(messaging) - .WaitFor(messaging); -``` - -## Anti-Patterns -- **Not using WaitFor()** — Can cause startup race conditions where apps try to connect before service is ready -- **Hardcoding connection strings** — Use `WithReference()` instead; Aspire injects correct connection string as environment variable -- **Skipping data persistence** — For stateful services (databases, auth), always use `WithDataVolume()` or `WithDataBindMount()` in development -- **Mixing stable and preview versions** — Check available package versions; if only preview exists, use it consistently -- **Forgetting client packages** — Hosting package (Aspire.Hosting.X) is for AppHost only; client projects need separate client packages (Aspire.X.Authentication, etc.) - -## When NOT to Use -- Simple in-process services that don't need orchestration -- Services already running externally (use connection strings directly) -- Production deployments (Aspire hosting is primarily for local development; production uses cloud services or Kubernetes) - -## Related Skills -- Central Package Management (CPM) patterns in .NET -- Docker container orchestration -- Service discovery and configuration in distributed applications diff --git a/.ai-team/skills/blazor-interactive-events/SKILL.md b/.ai-team/skills/blazor-interactive-events/SKILL.md deleted file mode 100644 index e14a537..0000000 --- a/.ai-team/skills/blazor-interactive-events/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: "blazor-interactive-events" -description: "How to enable event handlers in Blazor components with @rendermode" -domain: "blazor, ui, event-handling" -confidence: "medium" -source: "earned" ---- - -## Context -Blazor components with event handlers (OnClick, OnChange, OnSubmit, etc.) require explicit render mode declaration. Without it, event handlers silently fail - buttons appear but don't respond to clicks, dropdowns don't fire change events, etc. This is a common gotcha when creating interactive components. - -## Patterns - -### When @rendermode is Required -- Components with ANY event handler attributes: `OnClick`, `OnChange`, `OnInput`, `OnSubmit`, `OnFocus`, etc. -- Components with two-way binding: `@bind-Value`, `@bind-Text` -- Components calling methods on user interaction -- Shared components used in multiple pages that need interactivity - -### When @rendermode is NOT Required -- Static display components with no user interaction -- Components that only execute lifecycle methods (`OnInitialized`, `OnParametersSet`) without user input -- Pages that immediately redirect (Login.razor, Logout.razor that only call NavigateTo in OnInitialized) - -### Syntax -Place at the TOP of the component file, before other directives: - -```razor -@rendermode InteractiveServer -@using Microsoft.AspNetCore.Components.Authorization -@inject NavigationManager Navigation - -Click Me - -@code { - private void HandleClick() - { - // This will ONLY work with @rendermode InteractiveServer - } -} -``` - -### Alternative: Component-Level Rendermode -In parent components/layouts, you can set rendermode on usage: - -```razor - -``` - -But declaring it in the component itself is clearer and prevents mistakes. - -## Examples - -### LoginDisplay Component (Fixed) -```razor -@rendermode InteractiveServer -@using Microsoft.AspNetCore.Components.Authorization -@inject NavigationManager Navigation - - - - Logout - - - Login - - - -@code { - private void Login() => Navigation.NavigateTo("/login", forceLoad: true); - private void Logout() => Navigation.NavigateTo("/logout", forceLoad: true); -} -``` - -### Login.razor (No rendermode needed) -```razor -@page "/login" -@attribute [AllowAnonymous] -@inject NavigationManager Navigation - -@code { - // OnInitialized runs server-side without user interaction - // No event handlers = no rendermode needed - protected override void OnInitialized() - { - Navigation.NavigateTo("/authentication/login", forceLoad: true); - } -} -``` - -## Anti-Patterns - -### ❌ Missing rendermode with event handlers -```razor -@inject NavigationManager Navigation - -Click - -``` - -### ❌ Rendermode on redirect-only pages -```razor -@rendermode InteractiveServer -@page "/login" -@code { - protected override void OnInitialized() - { - Navigation.NavigateTo("/authentication/login", forceLoad: true); - } -} -``` - -### ✅ Correct: Rendermode only where needed -```razor -@rendermode InteractiveServer -Click -``` diff --git a/.ai-team/skills/blazor-oidc-authentication/SKILL.md b/.ai-team/skills/blazor-oidc-authentication/SKILL.md deleted file mode 100644 index eed34c7..0000000 --- a/.ai-team/skills/blazor-oidc-authentication/SKILL.md +++ /dev/null @@ -1,187 +0,0 @@ -# Blazor Server OpenID Connect Authentication - -**Confidence:** High -**Source:** Earned (NoteBookmark) - -Implementing OpenID Connect (OIDC) authentication in Blazor Server applications requires proper middleware configuration, component-level authorization, and cascading authentication state. - -## Pattern: Full OIDC Authentication Setup - -### 1. Dependencies -- Add `Microsoft.AspNetCore.Authentication.OpenIdConnect` package -- Built-in support for Cookie authentication already included - -### 2. Service Configuration (Program.cs) - -```csharp -builder.Services.AddAuthentication(options => -{ - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; -}) -.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) -.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => -{ - options.Authority = builder.Configuration["Keycloak:Authority"]; - options.ClientId = builder.Configuration["Keycloak:ClientId"]; - options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"]; - options.ResponseType = "code"; - options.SaveTokens = true; - options.GetClaimsFromUserInfoEndpoint = true; - options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); - - options.Scope.Clear(); - options.Scope.Add("openid"); - options.Scope.Add("profile"); - options.Scope.Add("email"); - - // Configure logout to pass id_token_hint to identity provider - options.Events = new OpenIdConnectEvents - { - OnRedirectToIdentityProviderForSignOut = async context => - { - // CRITICAL: Use async/await, never .Result in Blazor Server - var idToken = await context.HttpContext.GetTokenAsync("id_token"); - if (!string.IsNullOrEmpty(idToken)) - { - context.ProtocolMessage.IdTokenHint = idToken; - } - } - }; -}); - -builder.Services.AddAuthorization(); -builder.Services.AddCascadingAuthenticationState(); -``` - -### 3. Middleware Order (Program.cs) - -**Critical:** Authentication and Authorization must be placed after `UseAntiforgery()` and before `MapRazorComponents()`: - -```csharp -app.UseAuthentication(); -app.UseAuthorization(); -``` - -### 4. Authentication Endpoints - -Map login/logout endpoints that trigger OIDC flow: - -```csharp -app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => -{ - var authProperties = new AuthenticationProperties { RedirectUri = returnUrl ?? "/" }; - await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); -}); - -app.MapGet("/authentication/logout", async (HttpContext context) => -{ - var authProperties = new AuthenticationProperties { RedirectUri = "/" }; - await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); -}); -``` - -### 5. Routes Configuration (Routes.razor) - -Replace `RouteView` with `AuthorizeRouteView` and wrap in cascading state. Add custom NotAuthorized UI template: - -```razor -@using Microsoft.AspNetCore.Components.Authorization -@using Microsoft.AspNetCore.Authorization - - - - - - - @if (context.User.Identity?.IsAuthenticated != true) - { - - -

Authentication Required

-

You need to be logged in to access this page.

- - Login - -
- } - else - { - - -

Access Denied

-

You don't have permission to access this page.

- - Go to Home - -
- } -
-
- -
-
-
- -@code { - [Inject] private NavigationManager NavigationManager { get; set; } = default!; -} -``` - -### 6. Page-Level Authorization - -Add `@attribute [Authorize]` to protected pages and `@attribute [AllowAnonymous]` to public pages: - -```razor -@page "/protected-page" -@attribute [Authorize] -@using Microsoft.AspNetCore.Authorization -``` - -For public pages (home, login, logout, error): -```razor -@page "/" -@attribute [AllowAnonymous] -@using Microsoft.AspNetCore.Authorization -``` - -### 7. Login Display Component - -Use `` to show different UI based on auth state: - -```razor - - - Hello, @context.User.Identity?.Name - Logout - - - Login - - - -@code { - private void Login() => Navigation.NavigateTo("/login", forceLoad: true); - private void Logout() => Navigation.NavigateTo("/logout", forceLoad: true); -} -``` - -## Key Points - -1. **Configuration Source:** Support both appsettings.json and environment variables (e.g., `Keycloak__Authority`) -2. **Claims Mapping:** Configure `TokenValidationParameters` to map identity provider claims to .NET claims -3. **Force Reload:** Use `forceLoad: true` when navigating to login/logout to trigger full page reload and middleware execution -4. **Imports:** Add `@using Microsoft.AspNetCore.Authorization` and `@using Microsoft.AspNetCore.Components.Authorization` to `_Imports.razor` -5. **NotAuthorized Template:** Distinguish between unauthenticated (show login) and authenticated but unauthorized (show access denied) states -6. **Return URL:** Always preserve the returnUrl in login navigation so users return to intended page after authentication - -## Common Pitfalls - -- **Wrong middleware order:** Auth middleware must come after UseAntiforgery -- **Missing CascadingAuthenticationState:** Without this, components won't receive auth state updates -- **Forgetting forceLoad:** Without it, Blazor client-side navigation bypasses server middleware -- **HTTPS requirement:** Set `RequireHttpsMetadata = false` only in development environments -- **Missing AllowAnonymous:** Don't forget to add `[AllowAnonymous]` to public pages (home, login, logout, error) or users get redirect loops -- **Poor NotAuthorized UX:** Always provide clear messaging and action buttons in the NotAuthorized template -- **Blocking async calls:** Never use `.Result` on `GetTokenAsync()` in event handlers — it can cause deadlocks and token retrieval failures in Blazor Server. Always use `async context =>` and `await` diff --git a/.ai-team/skills/blazor-oidc-redirects/SKILL.md b/.ai-team/skills/blazor-oidc-redirects/SKILL.md deleted file mode 100644 index f0b094b..0000000 --- a/.ai-team/skills/blazor-oidc-redirects/SKILL.md +++ /dev/null @@ -1,178 +0,0 @@ ---- -name: "blazor-oidc-redirects" -description: "Proper handling of authentication redirects in Blazor with OpenID Connect" -domain: "authentication" -confidence: "high" -source: "earned" ---- - -## Context -When implementing OpenID Connect authentication in Blazor Server applications, redirect handling must be carefully designed to: -1. Preserve the user's intended destination after login -2. Use relative paths (not absolute URIs) for returnUrl parameters -3. Prevent redirect loops by marking authentication pages as anonymous -4. Handle deep linking scenarios properly -5. **Avoid NavigationManager during component initialization** — use HttpContext.ChallengeAsync instead - -## Patterns - -### Login Page Pattern (CORRECT - Using HttpContext) -```csharp -@page "/login" -@attribute [AllowAnonymous] -@using Microsoft.AspNetCore.Authorization -@using Microsoft.AspNetCore.Authentication -@using Microsoft.AspNetCore.Authentication.OpenIdConnect -@inject NavigationManager Navigation -@inject IHttpContextAccessor HttpContextAccessor - -@code { - protected override async Task OnInitializedAsync() - { - var uri = new Uri(Navigation.Uri); - var query = System.Web.HttpUtility.ParseQueryString(uri.Query); - var returnUrl = query["returnUrl"] ?? "/"; - - var httpContext = HttpContextAccessor.HttpContext; - if (httpContext != null) - { - var authProperties = new AuthenticationProperties - { - RedirectUri = returnUrl - }; - await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); - } - } -} -``` - -**Required Service Registration:** -```csharp -// In Program.cs -builder.Services.AddHttpContextAccessor(); -``` - -### LoginDisplay Button Handler -```csharp -private void Login() -{ - var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri); - if (string.IsNullOrEmpty(returnUrl)) - { - returnUrl = "/"; - } - Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: true); -} -``` - -### Public Page Attributes -All authentication-related pages must be marked with `[AllowAnonymous]`: -- Login page -- Logout page -- Home page (if publicly accessible) -- Error pages - -### Header Layout with AuthorizeView -Wrap header actions in FluentStack for proper spacing: -```razor - - - - - - -``` - -## Examples - -**Server-side authentication endpoint** (Program.cs): -```csharp -app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) => -{ - var authProperties = new AuthenticationProperties - { - RedirectUri = returnUrl ?? "/" - }; - await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); -}); -``` - -## Anti-Patterns - -❌ **Don't use NavigationManager.NavigateTo with forceLoad during OnInitialized:** -```csharp -// WRONG - causes NavigationException in Blazor Server -protected override void OnInitialized() -{ - Navigation.NavigateTo($"/authentication/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: true); -} -``` - -✅ **Do use HttpContext.ChallengeAsync directly:** -```csharp -// CORRECT - triggers server-side authentication flow without navigation exception -protected override async Task OnInitializedAsync() -{ - var httpContext = HttpContextAccessor.HttpContext; - if (httpContext != null) - { - await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties); - } -} -``` - -❌ **Don't pass full URI as returnUrl:** -```csharp -// WRONG - passes full URI like https://localhost:5001/posts -Navigation.NavigateTo($"/authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}", forceLoad: true); -``` - -✅ **Do use relative path:** -```csharp -// CORRECT - passes relative path like /posts -var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri); -Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: true); -``` - -❌ **Don't forget [AllowAnonymous] on public pages:** -```csharp -// WRONG - causes redirect loop -@page "/login" -``` - -✅ **Do mark authentication pages as anonymous:** -```csharp -// CORRECT - allows unauthenticated access -@page "/login" -@attribute [AllowAnonymous] -``` - -❌ **Don't place header items sequentially:** -```razor - - - - ... - -``` - -✅ **Do use FluentStack with spacing:** -```razor - - - - ... - -``` - -## Why This Matters - -**NavigationException Root Cause:** -- Blazor Server uses SignalR for interactive components -- NavigationManager.NavigateTo() with forceLoad: true forces a full page reload -- During OnInitialized(), the component hasn't fully rendered yet -- Forcing a navigation before render completion causes NavigationException -- HttpContext.ChallengeAsync() triggers authentication without client-side navigation, avoiding the exception - -**Key Principle:** -Use HttpContext for server-side operations (authentication challenges) and NavigationManager only for client-side navigation after component initialization is complete. diff --git a/.ai-team/skills/resilient-ai-json-parsing/SKILL.md b/.ai-team/skills/resilient-ai-json-parsing/SKILL.md deleted file mode 100644 index d51e313..0000000 --- a/.ai-team/skills/resilient-ai-json-parsing/SKILL.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -name: "resilient-ai-json-parsing" -description: "Patterns for safely deserializing JSON from AI providers with unpredictable formats" -domain: "ai-integration" -confidence: "medium" -source: "earned" ---- - -## Context -AI providers (OpenAI, Claude, Reka, etc.) can return structured JSON in unpredictable formats even with schema constraints. A field specified as a string might arrive as a number, boolean, object, or array depending on the model's interpretation. Standard JSON deserializers throw exceptions on type mismatches, causing runtime failures. - -This skill applies to any codebase that deserializes JSON from AI completions, especially when using structured output or JSON schema enforcement. - -## Patterns - -### Custom JsonConverter for Flexible Fields -For fields that might vary in type across AI responses, implement a custom `JsonConverter` that handles multiple `JsonTokenType` values instead of assuming a single type. - -**Key principles:** -- Handle ALL possible token types: String, Number, Boolean, Object, Array, Null -- Use try-catch around ALL parsing logic to prevent exceptions from bubbling up -- Call `reader.Skip()` in catch blocks to avoid leaving the reader in an invalid state -- Return a sensible default (null or empty) rather than throwing -- Prefer string types for fields with variable formats (gives maximum flexibility) - -### Date Handling from AI -Dates are especially problematic because AIs might return: -- ISO strings: `"2024-01-15T10:30:00Z"` -- Simple strings: `"2024-01-15"` or `"January 15, 2024"` -- Unix timestamps: `1704067200` (number) -- Objects: `{ "year": 2024, "month": 1, "day": 15 }` -- Invalid strings: `"sometime in 2024"` -- Booleans or arrays (rare but possible) - -**Pattern:** -1. Try to parse as `DateTime` and normalize to consistent format (e.g., `yyyy-MM-dd`) -2. If parsing fails, keep the original string (preserves info for debugging) -3. For complex types (objects/arrays), skip and return null -4. For booleans/numbers, convert to string representation - -## Examples - -### C# / System.Text.Json - -```csharp -public class DateOnlyJsonConverter : JsonConverter -{ - private const string DateFormat = "yyyy-MM-dd"; - - public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Null) - return null; - - try - { - switch (reader.TokenType) - { - case JsonTokenType.String: - var dateString = reader.GetString(); - if (string.IsNullOrEmpty(dateString)) - return null; - - // Try to parse and normalize to yyyy-MM-dd - if (DateTime.TryParse(dateString, out var date)) - return date.ToString(DateFormat); - - // Keep original string if not parseable - return dateString; - - case JsonTokenType.Number: - // Handle Unix timestamp - if (reader.TryGetInt64(out var timestamp)) - { - DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - var dateTime = timestamp > 2147483647 - ? epoch.AddMilliseconds(timestamp) - : epoch.AddSeconds(timestamp); - return dateTime.ToString(DateFormat); - } - break; - - case JsonTokenType.True: - case JsonTokenType.False: - // Handle unexpected boolean - return reader.GetBoolean().ToString(); - - case JsonTokenType.StartObject: - case JsonTokenType.StartArray: - // Skip complex types - reader.Skip(); - return null; - } - } - catch - { - // If parsing fails, skip the value and return null - try { reader.Skip(); } catch { /* ignore */ } - return null; - } - - return null; - } - - public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) - { - if (value == null) - writer.WriteNullValue(); - else - writer.WriteStringValue(value); - } -} - -// Usage in domain model -public class AiResponse -{ - [JsonPropertyName("publication_date")] - [JsonConverter(typeof(DateOnlyJsonConverter))] - public string? PublicationDate { get; set; } -} -``` - -### Testing Strategy -Always test ALL edge cases, not just happy paths: - -```csharp -[Fact] -public void Read_ShouldHandleBoolean_ReturnStringRepresentation() -{ - var json = @"{ ""publication_date"": true }"; - var result = JsonSerializer.Deserialize(json); - result!.PublicationDate.Should().Be("True"); -} - -[Fact] -public void Read_ShouldHandleObject_ReturnNull() -{ - var json = @"{ ""publication_date"": { ""year"": 2024 } }"; - var result = JsonSerializer.Deserialize(json); - result!.PublicationDate.Should().BeNull(); -} - -[Fact] -public void Read_ShouldHandleInvalidString_ReturnOriginal() -{ - var json = @"{ ""publication_date"": ""sometime in 2024"" }"; - var result = JsonSerializer.Deserialize(json); - result!.PublicationDate.Should().Be("sometime in 2024"); -} -``` - -## Anti-Patterns -- **Assuming AI respects schemas** — Even with JSON schema enforcement, models can produce unexpected types -- **Throwing on parse failures** — This breaks the entire deserialization. Always catch and degrade gracefully -- **Not calling reader.Skip()** — Failing to skip invalid tokens leaves the reader in a broken state -- **Using strongly-typed dates (DateTime, DateOnly)** — These force type constraints. Use `string?` for flexibility -- **Only testing happy paths** — The whole point is handling unexpected input. Test booleans, objects, arrays, invalid formats - -## When NOT to Use -- Data from controlled sources (your own API, database) -- User input that you validate before parsing -- Internal serialization where you control both ends - -This pattern is specifically for external, unpredictable data sources like AI model outputs. diff --git a/.ai-team/skills/resilient-json-deserialization/SKILL.md b/.ai-team/skills/resilient-json-deserialization/SKILL.md deleted file mode 100644 index b2e6220..0000000 --- a/.ai-team/skills/resilient-json-deserialization/SKILL.md +++ /dev/null @@ -1,32 +0,0 @@ -# Resilient JSON Deserialization for LLM Outputs - -**Confidence:** Medium -**Source:** Earned (NoteBookmark) - -When consuming JSON generated by Large Language Models (LLMs), standard strict deserialization is insufficient due to frequent format hallucinations (e.g., returning an object or array where a string is expected, or swapping date formats). - -## Pattern: The Fallback Converter - -Implement `JsonConverter` with a priority chain: - -1. **Strict Type Check:** If the token matches the target type, read it. -2. **Heuristic Conversion:** If the token is a compatible primitive (e.g., Number for Date), attempt conversion. -3. **Graceful Skip:** If the token is a complex type (Object/Array) where a primitive is expected, use `reader.Skip()` to advance the reader and return `default`/`null`. -4. **Global Catch:** Wrap the read logic in `try/catch` to return `null` rather than crashing the entire payload deserialization. - -## Example (Date Handling) - -```csharp -switch (reader.TokenType) -{ - case JsonTokenType.String: - // Try Parse -> Return formatted - // Fail Parse -> Return raw string - case JsonTokenType.Number: - // Heuristic: Int32.MaxValue separates Seconds from Milliseconds - case JsonTokenType.StartObject: - case JsonTokenType.StartArray: - reader.Skip(); // CRITICAL: Must skip to advance reader position - return null; -} -``` diff --git a/.ai-team/skills/squad-conventions/SKILL.md b/.ai-team/skills/squad-conventions/SKILL.md deleted file mode 100644 index 7ef2aa4..0000000 --- a/.ai-team/skills/squad-conventions/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: "squad-conventions" -description: "Core conventions and patterns used in the Squad codebase" -domain: "project-conventions" -confidence: "high" -source: "manual" ---- - -## Context -These conventions apply to all work on the Squad CLI tool (`create-squad`). Squad is a zero-dependency Node.js package that adds AI agent teams to any project. Understanding these patterns is essential before modifying any Squad source code. - -## Patterns - -### Zero Dependencies -Squad has zero runtime dependencies. Everything uses Node.js built-ins (`fs`, `path`, `os`, `child_process`). Do not add packages to `dependencies` in `package.json`. This is a hard constraint, not a preference. - -### Node.js Built-in Test Runner -Tests use `node:test` and `node:assert/strict` — no test frameworks. Run with `npm test`. Test files live in `test/`. The test command is `node --test test/`. - -### Error Handling — `fatal()` Pattern -All user-facing errors use the `fatal(msg)` function which prints a red `✗` prefix and exits with code 1. Never throw unhandled exceptions or print raw stack traces. The global `uncaughtException` handler calls `fatal()` as a safety net. - -### ANSI Color Constants -Colors are defined as constants at the top of `index.js`: `GREEN`, `RED`, `DIM`, `BOLD`, `RESET`. Use these constants — do not inline ANSI escape codes. - -### File Structure -- `.ai-team/` — Team state (user-owned, never overwritten by upgrades) -- `.ai-team-templates/` — Template files copied from `templates/` (Squad-owned, overwritten on upgrade) -- `.github/agents/squad.agent.md` — Coordinator prompt (Squad-owned, overwritten on upgrade) -- `templates/` — Source templates shipped with the npm package -- `.ai-team/skills/` — Team skills in SKILL.md format (user-owned) -- `.ai-team/decisions/inbox/` — Drop-box for parallel decision writes - -### Windows Compatibility -Always use `path.join()` for file paths — never hardcode `/` or `\` separators. Squad must work on Windows, macOS, and Linux. All tests must pass on all platforms. - -### Init Idempotency -The init flow uses a skip-if-exists pattern: if a file or directory already exists, skip it and report "already exists." Never overwrite user state during init. The upgrade flow overwrites only Squad-owned files. - -### Copy Pattern -`copyRecursive(src, target)` handles both files and directories. It creates parent directories with `{ recursive: true }` and uses `fs.copyFileSync` for files. - -## Examples - -```javascript -// Error handling -function fatal(msg) { - console.error(`${RED}✗${RESET} ${msg}`); - process.exit(1); -} - -// File path construction (Windows-safe) -const agentDest = path.join(dest, '.github', 'agents', 'squad.agent.md'); - -// Skip-if-exists pattern -if (!fs.existsSync(ceremoniesDest)) { - fs.copyFileSync(ceremoniesSrc, ceremoniesDest); - console.log(`${GREEN}✓${RESET} .ai-team/ceremonies.md`); -} else { - console.log(`${DIM}ceremonies.md already exists — skipping${RESET}`); -} -``` - -## Anti-Patterns -- **Adding npm dependencies** — Squad is zero-dep. Use Node.js built-ins only. -- **Hardcoded path separators** — Never use `/` or `\` directly. Always `path.join()`. -- **Overwriting user state on init** — Init skips existing files. Only upgrade overwrites Squad-owned files. -- **Raw stack traces** — All errors go through `fatal()`. Users see clean messages, not stack traces. -- **Inline ANSI codes** — Use the color constants (`GREEN`, `RED`, `DIM`, `BOLD`, `RESET`). diff --git a/.ai-team/team.md b/.ai-team/team.md deleted file mode 100644 index 1183347..0000000 --- a/.ai-team/team.md +++ /dev/null @@ -1,21 +0,0 @@ -# Team - -**Project:** NoteBookmark -**Tech Stack:** .NET 9, Blazor, C#, Microsoft AI Agent Framework -**Owner:** fboucher (fboucher@outlook.com) - -## Project Context - -This is a Blazor-based bookmark management application with AI capabilities. Currently using custom AI services, migrating to Microsoft AI Agent Framework. - -## Roster - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Ripley | Lead | .ai-team/agents/ripley/charter.md | ✅ Active | -| Hicks | Backend Dev | .ai-team/agents/hicks/charter.md | ✅ Active | -| Newt | Frontend Dev | .ai-team/agents/newt/charter.md | ✅ Active | -| Hudson | Tester | .ai-team/agents/hudson/charter.md | ✅ Active | -| Bishop | Code Reviewer | .ai-team/agents/bishop/charter.md | ✅ Active | -| Scribe | Session Logger | .ai-team/agents/scribe/charter.md | ✅ Active | -| Ralph | Work Monitor | — | 🔄 Monitor | From 1396ee95802467c188676bfa2fc28bed0b9ec90d Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 17 Feb 2026 07:07:34 -0500 Subject: [PATCH 11/14] docs: Updates .NET version and doc link Updates the .NET version badge in README.md to 10.0. Fixes a typo in the Keycloak authentication setup documentation link in README.md. --- README.md | 4 ++-- docs/{KEYCLOAK_AUTH.md => keycloak-setup.md} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/{KEYCLOAK_AUTH.md => keycloak-setup.md} (100%) diff --git a/README.md b/README.md index 2351ff9..925a1f0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Note Bookmark -![GitHub Release](https://img.shields.io/github/v/release/fboucher/NoteBookmark) ![.NET](https://img.shields.io/badge/9.0-512BD4?logo=dotnet&logoColor=fff) [![Unit Tests](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml/badge.svg)](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml) [![Reka AI](https://img.shields.io/badge/Power%20By-2E2F2F?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NjYuOTQgNjgxLjI2Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiBub25lOwogICAgICB9CgogICAgICAuY2xzLTIgewogICAgICAgIGZpbGw6ICNmMWVlZTc7CiAgICAgIH0KICAgIDwvc3R5bGU%2BCiAgPC9kZWZzPgogIDxyZWN0IGNsYXNzPSJjbHMtMSIgeD0iLS4yOSIgeT0iLS4xOSIgd2lkdGg9IjY2Ny4yMiIgaGVpZ2h0PSI2ODEuMzMiLz4KICA8Zz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxOC4zNCwwTDgyLjY3LjE2QzM2Ljg1LjE5LS4yOSwzNy4zOC0uMjksODMuMjd2MjM1LjEyaDc0LjkzVjcxLjc1aDI0My43M1YwaC0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik03Mi45NywzNjIuOTdIMHYzMTguMTZoNzIuOTd2LTMxOC4xNloiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxNS4zMywzNjIuODRoLTk5LjEzbC0xMDkuNSwxMDcuMjljLTEzLjk1LDEzLjY4LTIxLjgyLDMyLjM3LTIxLjgyLDUxLjkyczcuODYsMzguMjQsMjEuODIsNTEuOTJsMTA5LjUsMTA3LjI5aDEwMS42M2wtMTYyLjQ1LTE2MS43MiwxNTkuOTUtMTU2LjY3di0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0zNDguNTksODIuOTJ2MTUyLjIzYzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMjMwLjI4di03MS43OGgtMjQwLjMzVjg1Ljg3YzAtNy43NCw2LjI4LTE0LjA2LDE0LjA1LTE0LjA2aDE0NC4zMmM3Ljc0LDAsMTQuMDUsNi4yOCwxNC4wNSwxNC4wNiwwLDUuOS0zLjcxLDExLjE3LTkuMjMsMTMuMmwtMTQ3LjQ1LDU2LjIzdjcwLjczbDE3NC41Ny02Mi4yYzMzLjA0LTExLjgsNTUuMTEtNDMuMTMsNTUuMTEtNzguMjZ2LTIuNjdDNjY3LDM3LDYyOS44LS4xOSw1ODMuOTUtLjE5aC0xNTIuMjdjLTQ1Ljg5LDAtODMuMDUsMzcuMTktODMuMDUsODMuMTFoLS4wM1oiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTY2Ni45NCw1OTguMTJ2LTE1Mi4yM2MwLTQ1Ljg5LTM3LjE2LTgzLjExLTgzLjA1LTgzLjExaC0yMzAuMjh2NzEuNzhoMjQwLjMzdjE2MC42MWMwLDcuNzQtNi4yOCwxNC4wNi0xNC4wNSwxNC4wNmgtMTQ0LjMxYy03Ljc0LDAtMTQuMDUtNi4yOC0xNC4wNS0xNC4wNiwwLTUuOSwzLjcxLTExLjE3LDkuMjMtMTMuMmwxNDcuNDUtNTYuMjN2LTcwLjczbC0xNzQuNTcsNjIuMmMtMzMuMDQsMTEuOC01NS4xMSw0My4xMy01NS4xMSw3OC4yNnYyLjY3YzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMTUyLjI3YzQ1Ljg5LDAsODMuMDUtMzcuMTksODMuMDUtODMuMTFoLjAzWiIvPgogIDwvZz4KPC9zdmc%2B&logoSize=auto&labelColor=2E2F2F&color=F1EEE7)](https://reka.ai/) +![GitHub Release](https://img.shields.io/github/v/release/fboucher/NoteBookmark) ![.NET](https://img.shields.io/badge/10.0-512BD4?logo=dotnet&logoColor=fff) [![Unit Tests](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml/badge.svg)](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml) [![Reka AI](https://img.shields.io/badge/Power%20By-2E2F2F?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NjYuOTQgNjgxLjI2Ij4KICA8ZGVmcz4KICAgIDxzdHlsZT4KICAgICAgLmNscy0xIHsKICAgICAgICBmaWxsOiBub25lOwogICAgICB9CgogICAgICAuY2xzLTIgewogICAgICAgIGZpbGw6ICNmMWVlZTc7CiAgICAgIH0KICAgIDwvc3R5bGU%2BCiAgPC9kZWZzPgogIDxyZWN0IGNsYXNzPSJjbHMtMSIgeD0iLS4yOSIgeT0iLS4xOSIgd2lkdGg9IjY2Ny4yMiIgaGVpZ2h0PSI2ODEuMzMiLz4KICA8Zz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxOC4zNCwwTDgyLjY3LjE2QzM2Ljg1LjE5LS4yOSwzNy4zOC0uMjksODMuMjd2MjM1LjEyaDc0LjkzVjcxLjc1aDI0My43M1YwaC0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik03Mi45NywzNjIuOTdIMHYzMTguMTZoNzIuOTd2LTMxOC4xNloiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTMxNS4zMywzNjIuODRoLTk5LjEzbC0xMDkuNSwxMDcuMjljLTEzLjk1LDEzLjY4LTIxLjgyLDMyLjM3LTIxLjgyLDUxLjkyczcuODYsMzguMjQsMjEuODIsNTEuOTJsMTA5LjUsMTA3LjI5aDEwMS42M2wtMTYyLjQ1LTE2MS43MiwxNTkuOTUtMTU2LjY3di0uMDNaIi8%2BCiAgICA8cGF0aCBjbGFzcz0iY2xzLTIiIGQ9Ik0zNDguNTksODIuOTJ2MTUyLjIzYzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMjMwLjI4di03MS43OGgtMjQwLjMzVjg1Ljg3YzAtNy43NCw2LjI4LTE0LjA2LDE0LjA1LTE0LjA2aDE0NC4zMmM3Ljc0LDAsMTQuMDUsNi4yOCwxNC4wNSwxNC4wNiwwLDUuOS0zLjcxLDExLjE3LTkuMjMsMTMuMmwtMTQ3LjQ1LDU2LjIzdjcwLjczbDE3NC41Ny02Mi4yYzMzLjA0LTExLjgsNTUuMTEtNDMuMTMsNTUuMTEtNzguMjZ2LTIuNjdDNjY3LDM3LDYyOS44LS4xOSw1ODMuOTUtLjE5aC0xNTIuMjdjLTQ1Ljg5LDAtODMuMDUsMzcuMTktODMuMDUsODMuMTFoLS4wM1oiLz4KICAgIDxwYXRoIGNsYXNzPSJjbHMtMiIgZD0iTTY2Ni45NCw1OTguMTJ2LTE1Mi4yM2MwLTQ1Ljg5LTM3LjE2LTgzLjExLTgzLjA1LTgzLjExaC0yMzAuMjh2NzEuNzhoMjQwLjMzdjE2MC42MWMwLDcuNzQtNi4yOCwxNC4wNi0xNC4wNSwxNC4wNmgtMTQ0LjMxYy03Ljc0LDAtMTQuMDUtNi4yOC0xNC4wNS0xNC4wNiwwLTUuOSwzLjcxLTExLjE3LDkuMjMtMTMuMmwxNDcuNDUtNTYuMjN2LTcwLjczbC0xNzQuNTcsNjIuMmMtMzMuMDQsMTEuOC01NS4xMSw0My4xMy01NS4xMSw3OC4yNnYyLjY3YzAsNDUuOTIsMzcuMTYsODMuMTEsODMuMDUsODMuMTFoMTUyLjI3YzQ1Ljg5LDAsODMuMDUtMzcuMTksODMuMDUtODMuMTFoLjAzWiIvPgogIDwvZz4KPC9zdmc%2B&logoSize=auto&labelColor=2E2F2F&color=F1EEE7)](https://reka.ai/) @@ -52,7 +52,7 @@ Voila! Your app is now secure. ## Documentation For detailed setup guides and configuration information: -- [Keycloak Authentication Setup](/docs/keycloak_auth.md) - Complete guide for setting up Keycloak authentication +- [Keycloak Authentication Setup](/docs/keycloak-setup.md.md) - Complete guide for setting up Keycloak authentication - [Docker Compose Deployment](/docs/docker-compose-deployment.md) - Deploy with Docker Compose (generate from Aspire or use provided files) ## Contributing diff --git a/docs/KEYCLOAK_AUTH.md b/docs/keycloak-setup.md similarity index 100% rename from docs/KEYCLOAK_AUTH.md rename to docs/keycloak-setup.md From 3cd41948e7a54553a6242fbca5c6a2a92df7ed4e Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:22:43 -0500 Subject: [PATCH 12/14] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docker-compose/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose/docker-compose.yaml b/docker-compose/docker-compose.yaml index 4df4523..3afe790 100644 --- a/docker-compose/docker-compose.yaml +++ b/docker-compose/docker-compose.yaml @@ -45,7 +45,7 @@ services: HTTP_PORTS: "8004" services__api__http__0: "http://api:8000" services__keycloak__http__0: "http://keycloak:8080" - Keycloak__Authority: "${KEYCLOAK_AUTHORITY:-http://localhost:8080/realms/notebookmark}" + Keycloak__Authority: "${KEYCLOAK_AUTHORITY:-http://keycloak:8080/realms/notebookmark}" Keycloak__ClientId: "${KEYCLOAK_CLIENT_ID:-notebookmark}" Keycloak__ClientSecret: "${KEYCLOAK_CLIENT_SECRET}" ports: From 99c915d1efb2f8961d4c8acef00a8844d3919c9f Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:24:18 -0500 Subject: [PATCH 13/14] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/docker-compose-deployment.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docker-compose-deployment.md b/docs/docker-compose-deployment.md index 8e6f354..acdf9e2 100644 --- a/docs/docker-compose-deployment.md +++ b/docs/docker-compose-deployment.md @@ -125,7 +125,7 @@ Access the application at: - **API**: http://localhost:8001 - **Keycloak**: http://localhost:8080 -**First-time setup:** Keycloak needs to be configured with the realm settings. See [Keycloak Setup Guide](KEYCLOAK_SETUP.md) for detailed instructions. +**First-time setup:** Keycloak needs to be configured with the realm settings. See [Keycloak Setup Guide](./keycloak-setup.md) for detailed instructions. ## Stopping the Application From b4007402197deaeba17f31a6a371dbd5412b334b Mon Sep 17 00:00:00 2001 From: Frank Boucher <2404846+fboucher@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:29:44 -0500 Subject: [PATCH 14/14] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- src/NoteBookmark.BlazorApp/Program.cs | 31 +++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 925a1f0..ca99229 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Voila! Your app is now secure. ## Documentation For detailed setup guides and configuration information: -- [Keycloak Authentication Setup](/docs/keycloak-setup.md.md) - Complete guide for setting up Keycloak authentication +- [Keycloak Authentication Setup](/docs/keycloak-setup.md) - Complete guide for setting up Keycloak authentication - [Docker Compose Deployment](/docs/docker-compose-deployment.md) - Deploy with Docker Compose (generate from Aspire or use provided files) ## Contributing diff --git a/src/NoteBookmark.BlazorApp/Program.cs b/src/NoteBookmark.BlazorApp/Program.cs index 751cf1c..d169a42 100644 --- a/src/NoteBookmark.BlazorApp/Program.cs +++ b/src/NoteBookmark.BlazorApp/Program.cs @@ -69,15 +69,38 @@ .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { - options.Authority = builder.Configuration["Keycloak:Authority"]; + var authority = builder.Configuration["Keycloak:Authority"]; + options.Authority = authority; options.ClientId = builder.Configuration["Keycloak:ClientId"]; options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"]; options.ResponseType = "code"; options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; - // Allow overriding RequireHttpsMetadata via configuration for development/docker scenarios - options.RequireHttpsMetadata = builder.Configuration.GetValue("Keycloak:RequireHttpsMetadata") - ?? !builder.Environment.IsDevelopment(); + + // Allow overriding RequireHttpsMetadata via configuration for development/docker scenarios. + // If not explicitly configured, relax the requirement when running in a container against HTTP Keycloak. + var requireHttpsConfigured = builder.Configuration.GetValue("Keycloak:RequireHttpsMetadata"); + var isRunningInContainer = string.Equals( + System.Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), + "true", + StringComparison.OrdinalIgnoreCase); + + if (requireHttpsConfigured.HasValue) + { + options.RequireHttpsMetadata = requireHttpsConfigured.Value; + } + else + { + var defaultRequireHttps = !builder.Environment.IsDevelopment(); + if (isRunningInContainer && + !string.IsNullOrEmpty(authority) && + authority.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + defaultRequireHttps = false; + } + + options.RequireHttpsMetadata = defaultRequireHttps; + } options.Scope.Clear(); options.Scope.Add("openid");