From d620b82861708b150f2d71533cf8869df8e83810 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Sat, 24 Oct 2026 01:10:46 +0300 Subject: [PATCH 01/44] Install DotNetBoost.Auditing beside the other three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the fourth library from nuget.org — Core, EntityFrameworkCore and API at 1.0.0-preview.1 — and composes it the way the other three are: one Apply* line in AppDbContext, one block in AddInfrastructure, its own endpoints mapped in Program.cs, and no edit to any of the three already installed. What it cost is the interesting part, and it is written down rather than worked around. The library's change-tracker capture cannot be attached to this application's DbContext: DotNetBoost.MultiLingual's Translation has a composite key that an audit entry cannot address, and the library's three remedies — a surrogate key, [AuditIgnore], recording by hand — all belong to whoever owns the entity, which for the first two is not this application. So the six catalogue write slices record their own changes through IAuditManager and AuditDiff. Updates and deletes stay in one SaveChanges with the change they describe, because the EF Core store tracks an entry rather than writing it; a creation cannot, and Composition/CreationTrail.cs is that exception and its price. The other libraries' own tables are therefore not in the trail at all, and GET /api/audit/entities says so. [AuditIgnore] sits beside [MultiLingual] on Category.Name and Description: a trail has no language, so a property whose value depends on one cannot be stated in it. Said with the engine's own attribute so every way of recording honours it and the discovery endpoint reports it. The endpoints keep the library's default and require an authenticated caller, which is why the sample grew Api/Authentication/SampleActorAuthentication.cs — a header-trusting stand-in that verifies nothing, says so at length, and gives UseHttpContextActor a real actor to record. Erasure and prune are not mapped. Tests: AuditRuleTests asserts every translated property is an ignored one, read through each library's own metadata; AuditTrailTests drives the four new routes in both language modes, including that a refused write records nothing — the entry rolls back with the save. ZeroCouplingTests covers four assemblies and KeyFormatAgreementTests three addressing versions. 139 tests, and every count the documents state is updated with them. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 8 +- ARCHITECTURE-SLICES.md | 18 +- Directory.Packages.props | 21 +- README.md | 353 +++++++++++++++--- .../SampleActorAuthentication.cs | 75 ++++ .../DotNetBoostSample.Api.csproj | 4 + src/DotNetBoostSample.Api/Program.cs | 123 +++++- .../Catalog/Categories/CreateCategory.cs | 10 +- .../Catalog/Categories/DeleteCategory.cs | 10 +- .../Catalog/Categories/UpdateCategory.cs | 27 +- .../Catalog/Products/CreateProduct.cs | 10 +- .../Catalog/Products/DeleteProduct.cs | 11 +- .../Catalog/Products/UpdateProduct.cs | 20 +- .../Composition/CreationTrail.cs | 77 ++++ .../DependencyInjection.cs | 5 + .../DotNetBoostSample.Application.csproj | 4 + .../Catalog/Category.cs | 12 +- .../Catalog/Product.cs | 22 +- .../DotNetBoostSample.Domain.csproj | 11 +- .../DependencyInjection.cs | 114 +++++- .../DotNetBoostSample.Infrastructure.csproj | 4 + .../Persistence/AppDbContext.cs | 47 ++- .../Persistence/DatabaseInitialiser.cs | 13 +- .../DotNetBoostSample.Tests/AuditRuleTests.cs | 99 +++++ .../Integration/AuditTrailTests.cs | 304 +++++++++++++++ .../Integration/Http.cs | 26 ++ .../Integration/OpenApiShapeTests.cs | 23 +- .../Integration/RemainingRouteTests.cs | 6 +- .../KeyFormatAgreementTests.cs | 45 ++- .../ZeroCouplingTests.cs | 38 +- 30 files changed, 1388 insertions(+), 152 deletions(-) create mode 100644 src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs create mode 100644 src/DotNetBoostSample.Application/Composition/CreationTrail.cs create mode 100644 tests/DotNetBoostSample.Tests/AuditRuleTests.cs create mode 100644 tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a55aa32..7f8ff44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ # The sample's claims, checked on a machine that is not the author's. # -# This repository argues that what it says is tested rather than asserted — the three libraries +# This repository argues that what it says is tested rather than asserted — the four libraries # never reference each other, no endpoint's response changes with the base language, every # published route is called, and even the counts in the README are pinned. All of that was only # ever true on one laptop until this file existed. @@ -49,7 +49,7 @@ jobs: key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: nuget-${{ runner.os }}- - # The three DotNetBoost libraries come from nuget.org like anyone else's — there is no + # The four DotNetBoost libraries come from nuget.org like anyone else's — there is no # project reference to them and no local feed, which is the point of the sample. - name: Restore run: dotnet restore @@ -60,8 +60,8 @@ jobs: # ubuntu-latest ships a running Docker daemon, which Testcontainers uses to start the real # PostgreSQL this suite needs — twice, since the integration classes boot the application in # both language modes. Without a runtime those tests SKIP rather than fail, so a green run - # here that skipped 95 of them would be no signal at all; the step after this one says which - # happened. + # here that skipped 109 of them would be no signal at all; the step after this one says + # which happened. - name: Test run: | dotnet test DotNetBoostSample.slnx \ diff --git a/ARCHITECTURE-SLICES.md b/ARCHITECTURE-SLICES.md index 6708eda..c11e2e0 100644 --- a/ARCHITECTURE-SLICES.md +++ b/ARCHITECTURE-SLICES.md @@ -5,7 +5,7 @@ the two disagreed the code won — §10 lists what changed on contact with the c written while the work sat on `feat/vertical-slices-cqrs` and still argues with itself about whether to merge it; §11.3 says how that ended. -The solution builds clean on `net8.0` and `net10.0` with `TreatWarningsAsErrors`, and all 121 tests +The solution builds clean on `net8.0` and `net10.0` with `TreatWarningsAsErrors`, and all 139 tests pass, including the integration suite that boots the real `Program.cs` against a throwaway PostgreSQL and drives every route over HTTP. No endpoint's URL, status code or JSON shape changed. @@ -55,7 +55,10 @@ compile against DotNetBoost's own `net8.0` TFM. ## 1. One concern, stated once This repo was **1,788 lines of C# in 13 files** when this was written, and the README's thesis is -that the integration between three independent libraries is *five files, none of them large*. +that the integration between three independent libraries is *five files, none of them large*. A +fourth library has since been installed, and that count is now eight files — see the README; the +reasoning below is unaffected, and the one new file it would have changed its mind about +(`Composition/CreationTrail.cs`) is discussed there. Clean architecture plus vertical slices turns one project into four and moves the composition behind a dispatcher. The behaviour does not change. For a monolith heading to a hundred endpoints that is the right trade; for a sample whose job is to make a composition claim visible in one @@ -190,9 +193,10 @@ src/DotNetBoostSample.Api/ appsettings*.json ``` -Eleven slices, each one file. The three library endpoint sets (`/api/settings`, `/api/media`, -`/api/languages`, `/api/translations`) are untouched — they are still four `Map*Endpoints()` calls -in `Program.cs`, and this architecture does not reach them. +Eleven slices, each one file. The library endpoint sets (`/api/settings`, `/api/media`, +`/api/languages`, `/api/translations`, and `/api/audit` since a fourth library was installed) are +untouched — they are still one `Map*Endpoints()` call each in `Program.cs`, five of them now, and +this architecture does not reach them. --- @@ -660,7 +664,7 @@ about four queries per page could be a unit test rather than a comment. | **Projects and files** | 1 → 4 projects, and 13 → 42 C# files in `src/`. The eleven slices account for eleven of the twenty-nine new ones; the rest is the dispatcher, the result type, the endpoint scan, four shared records, two EF configurations, and the refusal handling §10a added after this table was first written — the validation and `DbUpdate` handlers, the error-translator interface and its Postgres implementation. Nothing it does is new. | | **Indirection** | A route no longer shows you what it does. `MapGet → IDispatcher → Wrapper<,> → behaviors → Handler` is four hops for a query whose body is unchanged, and the stack trace in a debugger gets a reflection frame in the middle. | | **Reflection** | Handler scanning and `MakeGenericType` at first dispatch. Fine for a server; a blocker under native AOT, where the answer is a source-generated dispatcher. | -| **Reach** | Covers 11 of the application's routes. The three libraries' own endpoints are not slices and will never be — so the dashboard talks to two different architectures, which is honest but worth knowing. | +| **Reach** | Covers 11 of the application's routes. The libraries' own endpoints are not slices and will never be — so the dashboard talks to two different architectures, which is honest but worth knowing. | | **Multi-targeting** | Two compiles, two analyzer passes, a CI matrix, and conditional EF versions. Buys you proof that the slices build against DotNetBoost's `net8.0` TFM. | | **The thesis** | The README's claim is "five files, none of them large." It is still true after this, and `Composition/` makes it more visible — but a reader now meets a dispatcher, a result type and four projects on the way to it. | @@ -692,7 +696,7 @@ that the package layout made the layering mistake unbuildable rather than merely **`AddInfrastructure` takes strings, not `IConfiguration`.** Reading configuration is the host's job, so `Program.cs` resolves the connection string and the media root and passes both. That keeps -Infrastructure's package list to EF Core, the three libraries' stores and the Npgsql provider — no +Infrastructure's package list to EF Core, the libraries' stores and the Npgsql provider — no `Microsoft.Extensions.Configuration`, no `Hosting` — and it means the "run the AppHost" message for a missing connection string is raised where it can say something useful. diff --git a/Directory.Packages.props b/Directory.Packages.props index ed003db..e663491 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,11 +5,15 @@ @@ -37,6 +41,21 @@ + + + + + + + + + + diff --git a/src/DotNetBoostSample.Api/Program.cs b/src/DotNetBoostSample.Api/Program.cs index b6f3b4a..62ea037 100644 --- a/src/DotNetBoostSample.Api/Program.cs +++ b/src/DotNetBoostSample.Api/Program.cs @@ -1,5 +1,7 @@ +using DotNetBoost.Auditing.API; using DotNetBoost.Media.API; using DotNetBoost.MultiLingual.API; +using DotNetBoostSample.Api.Authentication; using DotNetBoostSample.Application; using DotNetBoostSample.Application.Abstractions.Cqrs; using DotNetBoostSample.Application.Abstractions.Endpoints; @@ -8,20 +10,30 @@ using DotNetBoostSample.Application.Settings; using DotNetBoostSample.Infrastructure; using DotNetBoostSample.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authentication; using Scalar.AspNetCore; // ───────────────────────────────────────────────────────────────────────────── -// DotNetBoost sample — three independent libraries in one application. +// DotNetBoost sample — four independent libraries in one application. // -// Settings, Media and MultiLingual are installed side by side. Each is configured on its own -// terms, in its own block. There is no AddDotNetBoost(), no shared kernel package, and no +// Settings, Media, MultiLingual and Auditing are installed side by side. Each is configured on +// its own terms, in its own block. There is no AddDotNetBoost(), no shared kernel package, and no // integration package pairing any two of them: see Directory.Packages.props for the full list of // what is installed, and tests/ for the assertion that none of them references another. // -// What ties them together is five files, all of them application code, none of them large, and -// each one a place where this application says two libraries are talking about the same thing: +// The fourth arrived without moving the other three. Auditing's whole presence in this file is +// one delegate and one Map call; in the rest of the solution it is one Apply* line in +// AppDbContext, one block in AddInfrastructure, two attributes in Domain and one line in each of +// the six catalogue write slices. Nothing in the other three moved. That is the strongest +// evidence here for what "no shared kernel" is worth, because it is the only one measured by +// ADDING a library rather than by reading the ones already in — and the last two items on that +// list are also where the cost showed up, which is the rest of this comment. // -// • Infrastructure/Persistence/AppDbContext.cs — all three. One database, three Apply* +// What ties them together is eight files, all of them application and domain code, none of them +// large, and each one a place where this application says two libraries are talking about the +// same thing — except the last two, which are where it says they cannot: +// +// • Infrastructure/Persistence/AppDbContext.cs — all four. One database, four Apply* // calls, nothing else. // • Application/Composition/MediaText.cs — Media × MultiLingual. Reads a file's // per-language alt text for this API's own responses, via @@ -33,24 +45,46 @@ // Publishes `translated` on a settings property carrying // [MultiLingual], which the settings library cannot know // about. -// • Application/Settings/BrandingSettings.cs — all three, in BrandingProjector. Answers -// one settings group with its text in the request's +// • Application/Settings/BrandingSettings.cs — three of the four, in BrandingProjector. +// Answers one settings group with its text in the request's // language and its logo. // -// Four of those five turn out to be abstraction-only — they name DotNetBoost.*.Core and nothing -// else — which is why they sit in the application layer beside the slices that use them, in a -// folder called Composition. Only AppDbContext is infrastructure. Joining two libraries is not a -// persistence concern; joining them in ONE DATABASE is. +// Auditing added two more, and they are the first on this list that exist because two libraries +// DISAGREE rather than because they are talking about the same thing: +// +// • Application/Composition/CreationTrail.cs — Auditing → MultiLingual. A creation's entry +// needs a second save, and a second save on this context +// would make MultiLingual rewrite the text the first one +// stored — so it is written in a scope of its own. It +// exists at all because the two libraries cannot both watch +// one DbContext: Infrastructure/DependencyInjection.cs has +// that finding, which is the most useful thing in this +// repository about what independence costs. +// • Domain/Catalog/{Category,Product}.cs — MultiLingual → Auditing. [AuditIgnore] +// beside [MultiLingual] on the two translated properties: a +// trail has no language, so a property whose value depends +// on one cannot be stated in it. One sentence, said twice, +// which is why eight files are seven entries. +// +// Eight, counted the same way as five was. The README said then that a sixth would mean the claim +// had quietly stopped being true, and it has: this application now writes glue because two of its +// libraries cannot both watch one DbContext, which is a worse kind of glue than "these two tables +// are about one thing" and is worth saying out loud rather than absorbing into the count. // -// The count is five rather than a rounder number because every file joining two libraries is on -// the list; a sixth would mean the claim had quietly stopped being true. +// Seven of the eight turn out to be abstraction-only — they name DotNetBoost.*.Core and nothing +// else — which is why they sit in the application and domain layers beside the code that uses +// them. Only AppDbContext is infrastructure. Joining two libraries is not a persistence concern; +// joining them in ONE DATABASE is. +// +// The count is eight rather than a rounder number because every file joining two libraries is on +// the list, including the two that are there because a library could not be used as documented. // // It was more. Writing alt text is not here any more — DotNetBoost.MultiLingual ships the // translation endpoints now, and the dashboard's editor calls them directly — and branding was a // second endpoint of its own before ISettingProjector gave the join somewhere to live. // // ── And what this file is now ─────────────────────────────────────────────── -// Composition, and nothing else. The four ── blocks that used to configure the libraries are in +// Composition, and nothing else. The ── blocks that used to configure the libraries are in // Infrastructure/DependencyInjection.cs, moved whole; the catalogue's eleven routes are eleven // slices under Application/Catalog, one file each, holding their own query, response, bounds, // handler and URL. What is left here is reading configuration, which is the host's job, and @@ -91,7 +125,7 @@ // half in a column that was never filled. var baseLanguage = builder.Configuration["MultiLingual:BaseLanguage"]; -// ── The database and all three libraries ───────────────────────────────────── +// ── The database and all four libraries ────────────────────────────────────── // The third argument is this application's HTTP surface over translations, and it is here rather // than in AddInfrastructure because ExposeOverHttp lives in DotNetBoost.MultiLingual.API — the // package the HOST installs. Which translations are reachable is a routing decision; where they @@ -109,13 +143,30 @@ // Category and Product are deliberately NOT here. Their text is written as part of the product or // category it belongs to, through the typed accessor in the Create and Update slices, where it is // saved in the same transaction as the SKU and the price. A second way in would be a second answer. +// +// The fourth argument is the audit engine's two host-shaped decisions, and it is here for exactly +// the reason the third one is: both methods live in a *.API package, because both are facts about +// an HTTP request. +// +// UseHttpContextActor — the signed-in user of the current request is the actor on every entry +// the slices record during it. Without it the trail records "unknown" for +// all of them, which is honest and nearly useless. See +// Authentication/SampleActorAuthentication.cs for what signs in here, and +// for why a sample with no authentication grew some. +// UseLoggingObserver — writes what the engine decided NOT to record to ILogger. A change that +// was deliberately skipped is otherwise indistinguishable from one nobody +// made, and "the trail is silent about this entity" is a question someone +// asks in production, not in a code review. builder.Services.AddInfrastructure( connectionString, Path.GetFullPath(mediaRoot), baseLanguage, multiLingual => multiLingual.ExposeOverHttp(entities => entities .Allow(MediaText.Subject) - .Allow())); + .Allow()), + audit => audit + .UseHttpContextActor() + .UseLoggingObserver()); // ── The dispatcher, the eleven slices, the behaviors, the composition ──────── builder.Services.AddApplication(); @@ -146,12 +197,33 @@ builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); +// ── Who is asking ─────────────────────────────────────────────────────────── +// A header-trusting stand-in for authentication, and the only reason this sample has any: the +// audit endpoints require an authenticated caller because DotNetBoost.Auditing maps them that way, +// and the actor on an entry is whoever HttpContext.User says it is. Read +// Authentication/SampleActorAuthentication.cs before copying a line of this; it verifies nothing. +// +// Nothing else on this API requires a caller. The scheme is registered as the default so that a +// request carrying the header is authenticated everywhere — which is what gets a name onto the +// entries a catalogue write produces, not just onto the reads of the trail. +builder.Services + .AddAuthentication(SampleActorAuthenticationHandler.SchemeName) + .AddScheme( + SampleActorAuthenticationHandler.SchemeName, configureOptions: null); + +builder.Services.AddAuthorization(); + var app = builder.Build(); app.MapDefaultEndpoints(); app.UseExceptionHandler(); app.UseCors(); +// Before the endpoints, so HttpContext.User is populated by the time a handler saves anything — +// the actor is read while the change is being recorded, not afterwards. +app.UseAuthentication(); +app.UseAuthorization(); + // Creates the schema and seeds languages, categories, products and their translations. // A real application would use EF Core migrations; a sample that must run with one command // should not ask you to run two. @@ -164,8 +236,8 @@ app.MapOpenApi(); app.MapScalarApiReference(); -// ── Each library maps its own endpoints. Four calls, no coordination. ─────── -// All three tag their own routes, so the OpenAPI document groups them without help from here. +// ── Each library maps its own endpoints. Five calls, no coordination. ─────── +// All four tag their own routes, so the OpenAPI document groups them without help from here. // None of this is touched by the slice architecture: these are the libraries' endpoints, they are // not slices, and they never will be. app.MapSettingsEndpoints(); // /api/settings, /api/settings/{group}[/schema] @@ -173,6 +245,17 @@ app.MapLanguageEndpoints(); // /api/languages app.MapTranslationEndpoints(); // /api/translations/{entityName}/{entityId} +// /api/audit, /api/audit/{id}, and the two discovery routes. Called bare on purpose: +// +// • The reads REQUIRE AN AUTHENTICATED CALLER, because that is this library's default and the +// one it argues for. Passing o.RequireAuthorization = false would make the trail of this +// sample's every change public, and the library's own quick start says not to copy that line. +// • The erasure and prune endpoints are NOT MAPPED — o.MapErasure and o.MapPrune are off by +// default, so DELETE /api/audit/entity/… and POST /api/audit/prune do not exist here. They +// destroy history. Nothing in a sample needs to, and an endpoint that does should be asked for +// deliberately rather than inherited. +app.MapAuditEndpoints(); + // ── The application's own endpoints ────────────────────────────────────────── // Every IEndpoint in the Application assembly: /api/categories and /api/products, eleven routes // from eleven slices, each one declared in the file that answers it. @@ -185,7 +268,7 @@ /// WebApplicationFactory<Program> — top-level statements otherwise compile to a class /// no other assembly can see. /// -/// It carries no code on purpose. The tests run the real Program.cs above, with the real three +/// It carries no code on purpose. The tests run the real Program.cs above, with the real four /// registrations, because a test that boots a different composition proves nothing about this one. /// /// diff --git a/src/DotNetBoostSample.Application/Catalog/Categories/CreateCategory.cs b/src/DotNetBoostSample.Application/Catalog/Categories/CreateCategory.cs index 606afcd..222cd2f 100644 --- a/src/DotNetBoostSample.Application/Catalog/Categories/CreateCategory.cs +++ b/src/DotNetBoostSample.Application/Catalog/Categories/CreateCategory.cs @@ -2,6 +2,7 @@ using DotNetBoostSample.Application.Abstractions.Cqrs; using DotNetBoostSample.Application.Abstractions.Persistence; using DotNetBoostSample.Application.Catalog.Shared; +using DotNetBoostSample.Application.Composition; using DotNetBoostSample.Application.Localization; using DotNetBoostSample.Domain.Catalog; using FluentValidation; @@ -42,7 +43,8 @@ public Validator() public sealed class Handler( IAppDbContext db, - TranslatedText translatedText) : IRequestHandler + TranslatedText translatedText, + CreationTrail trail) : IRequestHandler { /// public async Task HandleAsync(Command request, CancellationToken ct) @@ -57,6 +59,12 @@ public async Task HandleAsync(Command request, CancellationToke // configured, the language it runs in decides where each value lands — see that class. await translatedText.SaveAsync(db, category, request.Translations, ct); + // And the entry that says who created it. Every other write in the catalogue records + // itself INTO its own save; a creation is the one that cannot, because the id an entry + // is addressed by is generated by the INSERT above. CreationTrail is where that, and + // the second reason it needs a context of its own, are written down. + await trail.RecordAsync(category, ct); + return new CategoryCreated(category.Id, category.Slug); } } diff --git a/src/DotNetBoostSample.Application/Catalog/Categories/DeleteCategory.cs b/src/DotNetBoostSample.Application/Catalog/Categories/DeleteCategory.cs index cee1f82..bb54100 100644 --- a/src/DotNetBoostSample.Application/Catalog/Categories/DeleteCategory.cs +++ b/src/DotNetBoostSample.Application/Catalog/Categories/DeleteCategory.cs @@ -1,3 +1,5 @@ +using DotNetBoost.Auditing.Core.Interfaces; +using DotNetBoost.Auditing.Core.Services; using DotNetBoost.Media.Core.Interfaces; using DotNetBoostSample.Application.Abstractions.Endpoints; using DotNetBoostSample.Application.Abstractions.Cqrs; @@ -20,7 +22,8 @@ public sealed record Command(int Id) : ICommand>; public sealed class Handler( IAppDbContext db, - IMediaManager media) : IRequestHandler> + IMediaManager media, + IAuditManager audit) : IRequestHandler> { /// public async Task> HandleAsync(Command request, CancellationToken ct) @@ -44,6 +47,11 @@ public async Task> HandleAsync(Command request, CancellationToken // and `category.Slug` is only reliable while the entity is still there to read it from. var slug = category.Slug; + // The entry carries the row's final values, and it outlives the row: nothing cascades + // to the trail, so after the save below this is the only record of what this category + // said. Recorded before the Remove so that both are in the same SaveChanges. + await audit.For(category).DeletedAsync(AuditDiff.FinalValues(category), ct: ct); + db.Categories.Remove(category); await db.SaveChangesAsync(ct); diff --git a/src/DotNetBoostSample.Application/Catalog/Categories/UpdateCategory.cs b/src/DotNetBoostSample.Application/Catalog/Categories/UpdateCategory.cs index ad2b215..6133d89 100644 --- a/src/DotNetBoostSample.Application/Catalog/Categories/UpdateCategory.cs +++ b/src/DotNetBoostSample.Application/Catalog/Categories/UpdateCategory.cs @@ -1,3 +1,5 @@ +using DotNetBoost.Auditing.Core.Interfaces; +using DotNetBoost.Auditing.Core.Services; using DotNetBoostSample.Application.Abstractions.Endpoints; using DotNetBoostSample.Application.Abstractions.Cqrs; using DotNetBoostSample.Application.Abstractions.Persistence; @@ -32,7 +34,8 @@ public Validator() public sealed class Handler( IAppDbContext db, - TranslatedText translatedText) : IRequestHandler> + TranslatedText translatedText, + IAuditManager audit) : IRequestHandler> { /// Answers with the slug, which is what the message is named after. public async Task> HandleAsync(Command request, CancellationToken ct) @@ -41,8 +44,30 @@ public async Task> HandleAsync(Command request, CancellationToken if (category is null) return Error.NotFound("Catalog.CategoryNotFound", request.Id); + // The before-image, which the explicit path has instead of a change tracker's original + // values. A second read of a row already in memory, and the library's documentation + // says plainly that this is what it costs: AuditDiff compares two instances, so there + // has to be a second instance. + // + // AsNoTracking, or EF would hand back the tracked entity — the same object that is + // about to be mutated, which would make every diff empty. + var before = await db.Categories.AsNoTracking().FirstAsync(c => c.Id == request.Id, ct); + category.Slug = request.Slug; + // Recorded BEFORE the save, which is what keeps the change and its trail in one + // transaction: the EF Core store tracks the entry rather than writing it, so + // SaveAsync below commits the slug and the entry together or neither. + // + // Guarded on Count, because the engine refuses to write an update of nothing and a PUT + // that changed nothing is not an error. It happens: the editor posts the slug back + // unchanged whenever only the translations were edited. Name and Description do move + // in that case and are in this list — they are dropped by the engine instead, for the + // reason written on Product.Name, so no entry is written either way. + var changes = AuditDiff.Between(before, category); + + if (changes.Count > 0) await audit.For(category).UpdatedAsync(changes, ct: ct); + await translatedText.SaveAsync(db, category, request.Translations, ct); return category.Slug; diff --git a/src/DotNetBoostSample.Application/Catalog/Products/CreateProduct.cs b/src/DotNetBoostSample.Application/Catalog/Products/CreateProduct.cs index dfc333b..21d6506 100644 --- a/src/DotNetBoostSample.Application/Catalog/Products/CreateProduct.cs +++ b/src/DotNetBoostSample.Application/Catalog/Products/CreateProduct.cs @@ -3,6 +3,7 @@ using DotNetBoostSample.Application.Abstractions.Persistence; using DotNetBoostSample.Application.Abstractions.Results; using DotNetBoostSample.Application.Catalog.Shared; +using DotNetBoostSample.Application.Composition; using DotNetBoostSample.Application.Localization; using DotNetBoostSample.Domain.Catalog; using FluentValidation; @@ -48,7 +49,8 @@ public Validator() public sealed class Handler( IAppDbContext db, - TranslatedText translatedText) : IRequestHandler> + TranslatedText translatedText, + CreationTrail trail) : IRequestHandler> { /// public async Task> HandleAsync(Command request, CancellationToken ct) @@ -83,6 +85,12 @@ public async Task> HandleAsync(Command request, Cancellat await translatedText.SaveAsync(db, product, request.Translations, ct); + // And who created it — after the save, because the id the entry is addressed by is the + // database's to assign, and in a unit of work of its own, because this one cannot be + // saved twice without MultiLingual rewriting the text just stored. CreationTrail has + // both reasons and what they cost. + await trail.RecordAsync(product, ct); + return new ProductCreated(product.Id, product.Sku); } } diff --git a/src/DotNetBoostSample.Application/Catalog/Products/DeleteProduct.cs b/src/DotNetBoostSample.Application/Catalog/Products/DeleteProduct.cs index f6e4f9a..6c2b5ef 100644 --- a/src/DotNetBoostSample.Application/Catalog/Products/DeleteProduct.cs +++ b/src/DotNetBoostSample.Application/Catalog/Products/DeleteProduct.cs @@ -1,3 +1,5 @@ +using DotNetBoost.Auditing.Core.Interfaces; +using DotNetBoost.Auditing.Core.Services; using DotNetBoost.Media.Core.Interfaces; using DotNetBoostSample.Application.Abstractions.Endpoints; using DotNetBoostSample.Application.Abstractions.Cqrs; @@ -20,7 +22,8 @@ public sealed record Command(int Id) : ICommand>; public sealed class Handler( IAppDbContext db, - IMediaManager media) : IRequestHandler> + IMediaManager media, + IAuditManager audit) : IRequestHandler> { /// public async Task> HandleAsync(Command request, CancellationToken ct) @@ -42,6 +45,12 @@ public async Task> HandleAsync(Command request, CancellationToken // Read before the row goes, for the same reason as the category's slug. var sku = product.Sku; + // The entry survives the row, with its final values — see DeleteCategory's handler. + // It is also the one place the trail and the images disagree about what "gone" means: + // the files this product held are deleted above, and the entry says only that the + // product was deleted. A trail of attachments would be DotNetBoost.Media's to keep. + await audit.For(product).DeletedAsync(AuditDiff.FinalValues(product), ct: ct); + // The product's own translations ARE removed automatically: MultiLingual's SaveChanges // interceptor sees the entity being deleted. db.Products.Remove(product); diff --git a/src/DotNetBoostSample.Application/Catalog/Products/UpdateProduct.cs b/src/DotNetBoostSample.Application/Catalog/Products/UpdateProduct.cs index a66e18d..85827fa 100644 --- a/src/DotNetBoostSample.Application/Catalog/Products/UpdateProduct.cs +++ b/src/DotNetBoostSample.Application/Catalog/Products/UpdateProduct.cs @@ -1,3 +1,5 @@ +using DotNetBoost.Auditing.Core.Interfaces; +using DotNetBoost.Auditing.Core.Services; using DotNetBoostSample.Application.Abstractions.Endpoints; using DotNetBoostSample.Application.Abstractions.Cqrs; using DotNetBoostSample.Application.Abstractions.Persistence; @@ -35,7 +37,8 @@ public Validator() public sealed class Handler( IAppDbContext db, - TranslatedText translatedText) : IRequestHandler> + TranslatedText translatedText, + IAuditManager audit) : IRequestHandler> { /// Answers with the SKU, which is what the message is named after. public async Task> HandleAsync(Command request, CancellationToken ct) @@ -50,10 +53,25 @@ public async Task> HandleAsync(Command request, CancellationToken if (!await db.Categories.AnyAsync(c => c.Id == request.CategoryId, ct)) return Error.NotFound("Catalog.CategoryNotFound", request.CategoryId); + // The before-image. UpdateCategory's handler explains the read and the AsNoTracking. + var before = await db.Products.AsNoTracking().FirstAsync(p => p.Id == request.Id, ct); + product.Sku = request.Sku; product.Price = request.Price; product.CategoryId = request.CategoryId; + // What moved, read off the type rather than written out here — so a column added to + // Product tomorrow is in the trail without this line changing, which is the one thing + // an if-chain per property reliably fails to do. Name and Description are compared and + // then dropped by the engine, because they carry [AuditIgnore]: see Product.Name. + // + // Recorded BEFORE the save, which is what keeps the change and its trail in one + // transaction — the EF Core store tracks the entry rather than writing it, so SaveAsync + // below commits the row and the entry together or neither. + var changes = AuditDiff.Between(before, product); + + if (changes.Count > 0) await audit.For(product).UpdatedAsync(changes, ct: ct); + await translatedText.SaveAsync(db, product, request.Translations, ct); return product.Sku; diff --git a/src/DotNetBoostSample.Application/Composition/CreationTrail.cs b/src/DotNetBoostSample.Application/Composition/CreationTrail.cs new file mode 100644 index 0000000..685dc9f --- /dev/null +++ b/src/DotNetBoostSample.Application/Composition/CreationTrail.cs @@ -0,0 +1,77 @@ +using DotNetBoost.Auditing.Core.Interfaces; +using DotNetBoost.Auditing.Core.Services; +using DotNetBoostSample.Application.Abstractions.Persistence; +using Microsoft.Extensions.DependencyInjection; + +namespace DotNetBoostSample.Application.Composition; + +/// +/// Records that an entity was created, in a unit of work of its own — the one entry this +/// application writes that is not committed with the change it describes, and the sixth +/// place where two of these libraries have to be told about each other. +/// +/// Why the catalogue records its own changes at all. DotNetBoost.Auditing normally +/// needs no help: UseAuditing(sp) attaches a SaveChangesInterceptor, it reads the +/// change tracker, and no handler mentions auditing ever again. That interceptor cannot be attached +/// to this application's context — Translation has a composite key an audit entry cannot +/// address — so the six write slices record explicitly instead. +/// Infrastructure/DependencyInjection.cs has that finding in full. Five of the six are one +/// line and cost nothing: the EF Core store tracks an entry rather than writing it, so an +/// update or a delete recorded before the save rides along in it. +/// +/// Why a creation is the one that cannot. An entry is addressed by the entity's key, +/// and a store-generated key does not exist until the INSERT has run. So a creation can only +/// be recorded afterwards — which is exactly the case the library's own interceptor defers to a +/// second pass. +/// +/// And why that second save cannot be this request's. The obvious shape — +/// await audit.For(product).CreatedAsync(…); await db.SaveChangesAsync(ct); — is wrong here, +/// and wrong in a way that costs data rather than a test: a second SaveChanges on a context +/// still tracking a [MultiLingual] entity writes that entity's in-memory text over +/// the translations the first save stored. For a product created through TranslatedText +/// those properties were never assigned — the text went to translation rows, not to the instance — +/// so the second save stores empty values for the request's language and the name reads back as +/// "". Two integration tests caught it, which is how it was found. +/// +/// So the entry goes in a scope of its own: a second , tracking +/// nothing but the entry and saving only it. The [MultiLingual] entity is not in that +/// context, so there is nothing for MultiLingual's interceptor to rewrite. +/// +/// What that costs, plainly. A creation and its trail are in two transactions. If the +/// first commits and this does not, the row exists and its creation is not recorded — the window +/// the library closes for its own interceptor by owning a transaction around both passes, which a +/// handler holding has no way to open. The honest fix is not more code +/// here: it is keys assigned in the application rather than by the database, which would put a +/// creation back on the single-save path with everything else. +/// +/// Scoped, like the other composition services, and injected into the two create slices so +/// that neither of them has to know any of the above — only that recording a creation is a call +/// rather than a save. +/// +public sealed class CreationTrail(IServiceScopeFactory scopes) +{ + /// + /// Records 's creation, with its initial values. + /// + /// + /// The entity as created, after the save that assigned its key. It is read, never + /// tracked: the audit engine takes the entity name, the key and the type off it, and + /// reads its properties — minus the ones carrying + /// [AuditIgnore], which the engine drops. + /// + /// The request's token. + public async Task RecordAsync(object entity, CancellationToken ct) + { + using var scope = scopes.CreateScope(); + + var audit = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Tracked by the new scope's context rather than written, like every other entry this + // application records — so this save is what makes it durable, and it is the only thing in + // that context to save. + await audit.For(entity).CreatedAsync(AuditDiff.InitialValues(entity), ct: ct); + + await db.SaveChangesAsync(ct); + } +} diff --git a/src/DotNetBoostSample.Application/DependencyInjection.cs b/src/DotNetBoostSample.Application/DependencyInjection.cs index 9893fb1..cb8f60c 100644 --- a/src/DotNetBoostSample.Application/DependencyInjection.cs +++ b/src/DotNetBoostSample.Application/DependencyInjection.cs @@ -70,6 +70,11 @@ public static IServiceCollection AddApplication(this IServiceCollection services // each take an IMultiLingualManager to hand straight on. services.AddScoped(); + // And the creation half of the audit trail — Auditing × MultiLingual, in the awkward + // direction. Scoped like the other two, and the reason it is a service at all rather than + // three lines in the two create slices is that it needs a SECOND scope: see CreationTrail. + services.AddScoped(); + return services; } diff --git a/src/DotNetBoostSample.Application/DotNetBoostSample.Application.csproj b/src/DotNetBoostSample.Application/DotNetBoostSample.Application.csproj index 6ba8eca..f0776b6 100644 --- a/src/DotNetBoostSample.Application/DotNetBoostSample.Application.csproj +++ b/src/DotNetBoostSample.Application/DotNetBoostSample.Application.csproj @@ -18,6 +18,10 @@ + + diff --git a/src/DotNetBoostSample.Domain/Catalog/Category.cs b/src/DotNetBoostSample.Domain/Catalog/Category.cs index 33c4372..4cb9e76 100644 --- a/src/DotNetBoostSample.Domain/Catalog/Category.cs +++ b/src/DotNetBoostSample.Domain/Catalog/Category.cs @@ -1,3 +1,4 @@ +using DotNetBoost.Auditing.Core.Attributes; using DotNetBoost.MultiLingual.Core.Attributes; namespace DotNetBoostSample.Domain.Catalog; @@ -19,10 +20,17 @@ public class Category { public int Id { get; set; } - /// Translatable. Read in the request's language, with fallback to the default. + /// + /// Translatable. Read in the request's language, with fallback to the default. + /// + /// [AuditIgnore] for the reason spelled out on : a trail + /// has no language, and this value has nothing else. + /// + [AuditIgnore] public string Name { get; set; } = string.Empty; - /// Translatable. + /// Translatable, and out of the trail for the same reason as . + [AuditIgnore] public string Description { get; set; } = string.Empty; /// NOT translatable — a stable identifier used in URLs, the same in every language. diff --git a/src/DotNetBoostSample.Domain/Catalog/Product.cs b/src/DotNetBoostSample.Domain/Catalog/Product.cs index 09dfbd4..043283b 100644 --- a/src/DotNetBoostSample.Domain/Catalog/Product.cs +++ b/src/DotNetBoostSample.Domain/Catalog/Product.cs @@ -1,3 +1,4 @@ +using DotNetBoost.Auditing.Core.Attributes; using DotNetBoost.MultiLingual.Core.Attributes; namespace DotNetBoostSample.Domain.Catalog; @@ -16,10 +17,27 @@ public class Product { public int Id { get; set; } - /// Translatable. + /// + /// Translatable. + /// + /// [AuditIgnore] is the second library's attribute on the first library's + /// property, and it is here because of what the first one does: this value depends on the + /// language, and an audit entry has no language. Name: "Desk" → "Writing desk" would be + /// a true sentence about one of four languages and silent about which — and in the mode this + /// sample ships in there is no column here at all, so the value a diff would compare is + /// whatever was hydrated for the language the request happened to be read in. + /// + /// Said with the engine's own attribute rather than filtered in the application, so that + /// every way of recording honours it — including GET /api/audit/entities/Product, + /// which reports the rule as Ignore rather than claiming a property the trail never + /// carries. Translated text has its own history to be given one day, in the Translations table, + /// with a language on each row. + /// + [AuditIgnore] public string Name { get; set; } = string.Empty; - /// Translatable. + /// Translatable, and out of the trail for the same reason as . + [AuditIgnore] public string Description { get; set; } = string.Empty; /// NOT translatable — a stock-keeping unit is the same string in every language. diff --git a/src/DotNetBoostSample.Domain/DotNetBoostSample.Domain.csproj b/src/DotNetBoostSample.Domain/DotNetBoostSample.Domain.csproj index dd9450a..8d4c48f 100644 --- a/src/DotNetBoostSample.Domain/DotNetBoostSample.Domain.csproj +++ b/src/DotNetBoostSample.Domain/DotNetBoostSample.Domain.csproj @@ -4,13 +4,18 @@ net8.0;net10.0 - + + bargain a domain entity carrying [Required] already makes. --> + + diff --git a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs index 1feb253..3a3765f 100644 --- a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs +++ b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs @@ -1,9 +1,11 @@ +using DotNetBoost.Auditing.Core; using DotNetBoost.Media.Core.Interfaces; using DotNetBoost.MultiLingual.Core; using DotNetBoost.Settings.Core.Interfaces; using DotNetBoostSample.Application.Abstractions.Persistence; using DotNetBoostSample.Application.Composition; using DotNetBoostSample.Application.Settings; +using DotNetBoostSample.Domain.Catalog; using DotNetBoostSample.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -12,18 +14,23 @@ namespace DotNetBoostSample.Infrastructure; /// -/// Where the three libraries' data actually goes — the four blocks that used to be the body of +/// Where the four libraries' data actually goes — the blocks that used to be the body of /// Program.cs, moved whole. /// -/// What did not change in the move. Still three independent registrations, in three -/// blocks, in any order, with no AddDotNetBoost() and no package pairing any two of them. -/// That is the sample's subject, and a restructure that quietly introduced a coordinating call -/// here would have deleted the thing it was reorganising. +/// What did not change in the move. Still independent registrations, one block each, +/// in any order, with no AddDotNetBoost() and no package pairing any two of them. That is +/// the sample's subject, and a restructure that quietly introduced a coordinating call here would +/// have deleted the thing it was reorganising. +/// +/// And what did not change when the fourth arrived. Auditing is a fourth block of +/// the same shape — AddAuditing(), a store, Build() — appended without a line of the +/// three above moving. It is the cheapest possible evidence for the claim: adding a library here +/// is additive, because there is nothing for it to be wired into. /// public static class DependencyInjection { /// - /// Registers the database and all three stores. + /// Registers the database and all four stores. /// /// Why this takes strings rather than IConfiguration. Reading configuration /// is the host's job — it is the one that knows about appsettings.json, Aspire's @@ -64,21 +71,41 @@ public static class DependencyInjection /// have put routing in the infrastructure layer, so the host passes its answer in instead. /// /// + /// + /// Whatever the host wants to add to the audit engine that needs ASP.NET Core. + /// + /// A delegate for the same reason is one, and it is the + /// same shape of decision: UseHttpContextActor() lives in + /// DotNetBoost.Auditing.API, because WHO is acting is a fact about the current request + /// and this layer has no requests. Where the trail is STORED is this file's business; whose + /// name goes on an entry is the host's. + /// + /// + /// Left null, the engine's default accessor answers null and every entry's actor is honestly + /// unknown — which is what a background job or a migration would record, and is a better + /// default than inventing "system". + /// + /// public static IServiceCollection AddInfrastructure( this IServiceCollection services, string connectionString, string mediaRoot, string? baseLanguage = null, - Action? exposeTranslations = null) + Action? exposeTranslations = null, + Action? configureAuditing = null) { // ── The database ───────────────────────────────────────────────────── - // One PostgreSQL database holds the application's two tables and all six library tables. + // One PostgreSQL database holds the application's two tables and every library table. // `UseMultiLingual(sp)` adds MultiLingual's EF Core interceptors, which is what makes a // plain `db.Products.ToListAsync()` in a handler come back already translated into the // request's language. It needs the (sp, options) overload of AddDbContext, so the // connection string is read from configuration by the host rather than through // AddNpgsqlDbContext. // + // There is NO `.UseAuditing(sp)` here, and that is the one thing on this branch that did + // not go the way the libraries' READMEs describe. See the Auditing block below: it is not + // a preference, and it is not an omission. + // // `ReplaceService` is the one addition the base language asked for here: the model this // context builds depends on that option, and EF's own cache key does not. See // MultiLingualModelCacheKeyFactory — it costs nothing in either mode and makes the two @@ -211,6 +238,77 @@ public static IServiceCollection AddInfrastructure( multiLingual.Build(); + // ── DotNetBoost.Auditing ───────────────────────────────────────────── + // Who changed what, when, and from what to what — in the AuditEntries and + // AuditPropertyChanges tables of the same database. + // + // Note what is NOT in this block, because it is the library's headline and it does hold: + // nothing was added to Category or Product. Domain still references one package, still for + // one attribute, and it is MultiLingual's. A trail is addressed by (entityName, entityId) + // in two tables of its own, so no column, no collection and no migration appears on an + // entity because it is audited. + // + // ── And one thing is missing that the library's README would have here ── + // + // `o.UseAuditing(sp)` on the context above — the SaveChangesInterceptor that reads the + // change tracker, which is how an application normally gets a trail without a line in a + // handler. It cannot be attached to THIS context, and the reason is the most useful thing + // this sample has learned from installing a fourth library: + // + // Translation has a composite primary key (EntityName, EntityId, PropertyName, + // LanguageCode). An audit entry records a single key. Give the entity a surrogate key, + // exclude it with [AuditIgnore], or record against it by hand with audit.For(name, id). + // + // That is the library's own exception, thrown on the first save that tracks a translation + // row — which is every catalogue write. All three of its suggestions are addressed to + // whoever owns the entity, and nobody here owns it: Translation is DotNetBoost. + // MultiLingual's, its lack of a surrogate key is documented as deliberate ("the natural + // key is unique by definition"), and an attribute cannot be applied to a type from the + // outside. RequireOptIn does not help either — the key is read before the engine asks + // whether the entity is audited at all. + // + // So this application records explicitly instead, which is the third suggestion and a + // first-class path in the library rather than a workaround: the six catalogue write slices + // each report their own change through IAuditManager and AuditDiff. What that costs is in + // the README under "What this costs"; what it buys back is that an entry is appended to + // this DbContext and committed by the SAME SaveChanges as the change it describes, so an + // update and its trail still land together or neither does. A creation is the exception, + // and Application/Composition/CreationTrail.cs is the exception written down. + // + // The honest summary: two independent libraries, in one DbContext, incompatible at the + // capture level — and no shared kernel to arbitrate, which is exactly the trade this + // repository is about. An ignore-list on the builder (`o.Ignore()`) would + // close it without either library knowing about the other, and that is what this sample + // would ask the library for next. + var auditing = services + .AddAuditing() + // The trail goes in AppDbContext, not in a store of its own, and that is what the + // transaction guarantee rests on: AppendAsync TRACKS the entry rather than saving it, + // so a handler that records before its SaveChangesAsync writes the change and the + // entry in one transaction. The library's Dapper and MongoDb stores write when they + // are called instead, and atomicity is then the application's to arrange. + .UseEntityFrameworkCore() + // The application's own two entities, by type — and on the explicit path this is + // load-bearing rather than decorative. It is what lets the engine apply a type's + // [AuditIgnore] and [AuditRedact] rules to a write addressed by name, what a prune + // would read a per-entity retention from, and what GET /api/audit/entities lists. + // + // DeclareEntities rather than DeclareEntitiesFrom(assembly), because the scan declares + // the types that CARRY one of the library's attributes and these two carry none — + // which is the point above restated. + .DeclareEntities(typeof(Category), typeof(Product)); + + // No retention is configured, deliberately. Retention does nothing until a prune runs, + // this sample schedules none, and POST /api/audit/prune is not mapped — so a number here + // would state a policy that nothing in the solution carries out. The trail grows forever, + // which for a sample seeded on first run is the honest answer; `o.Retention` plus a hosted + // service that calls PruneAsync is the real one. + + // And whose name goes on an entry, which only the host can answer. See the parameter. + configureAuditing?.Invoke(auditing); + + auditing.Build(); + return services; } } diff --git a/src/DotNetBoostSample.Infrastructure/DotNetBoostSample.Infrastructure.csproj b/src/DotNetBoostSample.Infrastructure/DotNetBoostSample.Infrastructure.csproj index df44b12..4780283 100644 --- a/src/DotNetBoostSample.Infrastructure/DotNetBoostSample.Infrastructure.csproj +++ b/src/DotNetBoostSample.Infrastructure/DotNetBoostSample.Infrastructure.csproj @@ -11,6 +11,10 @@ + + diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs b/src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs index 189135f..86debde 100644 --- a/src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs +++ b/src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs @@ -1,3 +1,4 @@ +using DotNetBoost.Auditing.EntityFrameworkCore; using DotNetBoost.Media.EntityFrameworkCore; using DotNetBoost.MultiLingual.EntityFrameworkCore; using DotNetBoost.Settings.EntityFrameworkCore; @@ -9,23 +10,33 @@ namespace DotNetBoostSample.Infrastructure.Persistence; /// -/// One PostgreSQL database, one DbContext, three libraries' tables alongside the application's +/// One PostgreSQL database, one DbContext, four libraries' tables alongside the application's /// own two. /// -/// This class is the only place in the solution where all three libraries are named together — -/// and note what that now amounts to: three independent Apply* calls in +/// This class is the only place in the solution where all four libraries are named together — +/// and note what that now amounts to: four independent Apply* calls in /// and nothing else. No library interface to implement and no /// DbSet to declare for them: each store reaches its own tables through /// Set<T>(), and this application goes through ISettingManager, -/// IMediaManager and IMultiLingualManager rather than the tables. Removing a -/// library means deleting its one line here. Nothing else in the file changes. +/// IMediaManager, IMultiLingualManager and IAuditManager rather than the +/// tables. Removing a library means deleting its one line here. Nothing else in the file changes. /// /// -/// It is also the one of the five composition files that is infrastructure. The other four -/// — MediaText, MediaTextCleanup, MultiLingualSchemaContributor and -/// BrandingProjector — depend on DotNetBoost.*.Core and nothing else, so they live -/// in the application layer beside the slices that use them. Joining two libraries turns out not -/// to be a persistence concern; joining them in one database is, and that is this file. +/// Auditing is the newest of the four and the one that says most about the arrangement. Its two +/// tables are mapped here like any other library's — but its capture is not attached to +/// this context, because Translation has a composite key that an audit entry cannot +/// address, and neither the attribute that would exclude it nor the key that would fix it belongs +/// to this application. So the catalogue's writes record themselves, one line per write slice. +/// DependencyInjection.cs has the whole finding; it is the one place where two of these +/// libraries, in one database, do not simply coexist. +/// +/// +/// It is also the one of the eight composition files that is infrastructure. The other +/// seven — MediaText, MediaTextCleanup, MultiLingualSchemaContributor, +/// BrandingProjector, CreationTrail and the two entities' attributes — depend on +/// DotNetBoost.*.Core and nothing else, so they live in the application and domain layers +/// beside the code that uses them. Joining two libraries turns out not to be a persistence +/// concern; joining them in one database is, and that is this file. /// /// /// is the application layer's view of it: the two DbSets and @@ -52,10 +63,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // ── The application's own mapping ──────────────────────────────────── // One IEntityTypeConfiguration per entity, in Configurations/, rather than two lambdas // here. Scanned rather than listed, so adding an entity adds a file and nothing else — - // and so this method stays what it is worth reading for: the three library calls below. + // and so this method stays what it is worth reading for: the four library calls below. modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); - // ── Three independent calls, in any order ──────────────────────────── + // ── Four independent calls, in any order ──────────────────────────── // Settings: the Settings table, with PostgreSQL column types. // The engine read off the context rather than named here, so switching provider in // Program.cs needs no matching edit. The explicit @@ -76,5 +87,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // base language. Passing `this` rather than nothing is what makes the mapping follow the // engine instead of the two having to be set to the same thing twice. modelBuilder.ApplyMultiLingual(this); + + // Auditing: the AuditEntries and AuditPropertyChanges tables. Two tables for every + // entity in the application, now and later — an entry points AT a row by (entityName, + // entityId) with deliberately no foreign key back, so auditing something new costs no + // migration and a deleted product leaves its history standing. The last part is not a + // side effect: the entry that records the deletion is the only copy of what the row said. + // + // Nothing is added to Category or Product by this call. That is the whole of the schema + // side, and it is deliberately separate from what makes a change get recorded — which + // here is the write slices calling IAuditManager, because the interceptor that would do + // it for them cannot be attached to this context. See DependencyInjection.cs. + modelBuilder.ApplyAuditing(); } } diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs b/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs index 3d1a350..66a813b 100644 --- a/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs +++ b/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs @@ -8,9 +8,16 @@ namespace DotNetBoostSample.Infrastructure.Persistence; /// -/// Creates the schema and puts enough in it to see all three libraries working on first run. -/// A real application would use dotnet ef migrations; a sample you start with one command -/// should not make you run two. +/// Creates the schema — including the fourth library's two tables — and puts enough in it to see +/// Settings, Media and MultiLingual working on first run. A real application would use +/// dotnet ef migrations; a sample you start with one command should not make you run two. +/// +/// The audit trail starts empty, on purpose. This seeder writes through the libraries +/// and the DbContext directly rather than through the catalogue's write slices, and the +/// slices are what record entries — there is no capture interceptor on this context to pick the +/// seed up (see DependencyInjection.cs). So the trail begins with the first change somebody +/// makes, which is also the more useful first thing to read: nobody is served by forty entries +/// that say "the seed did it". /// public static class DatabaseInitialiser { diff --git a/tests/DotNetBoostSample.Tests/AuditRuleTests.cs b/tests/DotNetBoostSample.Tests/AuditRuleTests.cs new file mode 100644 index 0000000..c43bd85 --- /dev/null +++ b/tests/DotNetBoostSample.Tests/AuditRuleTests.cs @@ -0,0 +1,99 @@ +using DotNetBoost.Auditing.Core.Metadata; +using DotNetBoost.MultiLingual.Core.Metadata; +using DotNetBoostSample.Domain.Catalog; +using Xunit; + +namespace DotNetBoostSample.Tests; + +/// +/// The one rule this application adds to DotNetBoost.Auditing's model: a property whose value +/// depends on the language is not in the trail. +/// +/// Why it needs asserting. An audit entry has no language column, so +/// Name: "Desk" → "Writing desk" would be a sentence about one of four languages and silent +/// about which — and in the mode the sample ships in, Name is not a column at all, so the +/// value a diff compares is whatever was hydrated for the reading language. The rule is stated with +/// [AuditIgnore] on Category and Product, where the engine reads it for every +/// way of recording, rather than filtered in the handlers that happen to record today. +/// +/// And why it is asserted like this. Not as a list of property names: the set of +/// translated properties is [MultiLingual]'s to decide and the set of ignored ones is +/// [AuditIgnore]'s, and the claim is that those two sets are the same. Written as a list, +/// the test would keep passing when someone adds a fifth translated property and forgets the second +/// attribute — which is exactly the mistake worth catching, because its only symptom is a trail +/// that confidently says the wrong thing. +/// +/// No database and no host: two attributes, read through each library's own metadata. Kept on +/// [Fact] rather than a [Theory] over the two entity types, because every count in +/// the documentation is one test per method — see . +/// +public class AuditRuleTests +{ + /// The entities this application declares to the audit engine. + private static readonly Type[] Audited = [typeof(Category), typeof(Product)]; + + [Fact] + public void A_translated_property_is_ignored_by_the_trail() + { + foreach (var entity in Audited) + { + var translated = MultiLingualMetadata.For(entity).Select(p => p.Name).Order().ToArray(); + + var ignored = AuditSchema.Describe(entity) + .Where(p => p.Rule == AuditPropertyRule.Ignore) + .Select(p => p.Name) + .Order() + .ToArray(); + + Assert.NotEmpty(translated); + + // Stated as the equality rather than two containment checks, so it fails in both + // directions: a translated property that is recorded, and an ignored property that is + // ignored for some other reason nobody wrote down. + Assert.Equal(translated, ignored); + } + } + + /// + /// And the other half, so the rule reads as a boundary rather than as "ignore things": the + /// columns a trail can state a change about are recorded, with their values. + /// + /// Slug and Sku are the two properties this sample marks + /// [NotMultiLingual] — the same string in every language — so they are precisely the + /// ones the rule above must not touch. A reader who assumed "translated entity, no trail" would + /// have both halves wrong. + /// + [Fact] + public void A_language_independent_column_is_recorded() + { + (Type Entity, string Property)[] recorded = + [ + (typeof(Category), nameof(Category.Slug)), + (typeof(Product), nameof(Product.Sku)), + (typeof(Product), nameof(Product.Price)) + ]; + + foreach (var (entity, property) in recorded) + { + Assert.Equal( + AuditPropertyRule.Record, + AuditSchema.Describe(entity).Single(p => p.Name == property).Rule); + } + } + + /// + /// Nothing is redacted, which is worth pinning rather than leaving to be noticed: neither + /// entity holds a secret, so an [AuditRedact] appearing here would mean some property's + /// values are being withheld from the trail by a change nobody wrote a test for. + /// + [Fact] + public void Nothing_in_the_catalogue_is_redacted() + { + foreach (var entity in Audited) + { + Assert.DoesNotContain( + AuditPropertyRule.Redact, + AuditSchema.Describe(entity).Select(p => p.Rule)); + } + } +} diff --git a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs new file mode 100644 index 0000000..fd947de --- /dev/null +++ b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs @@ -0,0 +1,304 @@ +using System.Net; +using System.Text.Json; +using Xunit; + +namespace DotNetBoostSample.Tests.Integration; + +/// +/// What the fourth library actually does here, over HTTP: a row's whole life in the trail, the name +/// of whoever changed it, the entry that outlives the row, and the two places this application's +/// arrangement shows through — translated text that cannot be in a trail, and a trail that is only +/// as wide as the slices that write it. +/// +/// Why these need the database. Every claim below is a claim about a transaction. +/// That an entry is committed with the change it describes, that a refused save takes its entry +/// with it, that a deleted product leaves its history standing — none of it is visible to a test +/// that reads attributes, and the last two are exactly where a hand-written recording path would +/// go wrong without saying so. +/// +/// Each test creates its own product. The tests in a collection share one database and the +/// trail is append-only, so a test that read "the newest entry" of a shared row would be reading +/// whatever ran before it. +/// +public abstract class AuditTrailTests(SampleApp app) +{ + /// + /// Create, change and delete one product, then read its trail: three entries, newest first, + /// with the values that moved. + /// + /// The delete is the one worth staring at. Nothing cascades to the trail — an entry + /// points at a row by name and key with no foreign key back — so the history is still there + /// after the row is not, which is the entry people actually come looking for. + /// + [RequiresDockerFact] + public async Task A_products_whole_life_is_in_the_trail_and_outlives_it() + { + var client = app.Client.AsActor(); + var id = await client.CreateProductAsync(app.SeededCategoryId, "Audited product"); + + await client.SendJsonCheckedAsync(HttpMethod.Put, $"/api/products/{id}", new + { + sku = $"AUD-{id}", + price = 42.50m, + categoryId = app.SeededCategoryId, + translations = new Dictionary>() + }); + + await client.SendJsonCheckedAsync(HttpMethod.Delete, $"/api/products/{id}"); + + // The row is gone. + Assert.True((await client.GetAsync($"/api/products/{id}")).IsNotFound()); + + // Its history is not. + var entries = await Trail(client, "Product", id); + + Assert.Equal(["Deleted", "Updated", "Created"], entries.Select(e => e.Str("action"))); + + // The update says what moved, old value → new. The create and the delete carry the row's + // initial and final values, which after a delete is the only copy of what it said. + var price = Changes(entries[1])["Price"]; + + Assert.Equal(("1.00", "42.50"), (price.Str("oldValue"), price.Str("newValue"))); + Assert.Equal("42.50", Changes(entries[0])["Price"].Str("oldValue")); + Assert.Null(Changes(entries[0])["Price"].GetProperty("newValue").GetString()); + + // And the key the entry is addressed by is the product's own, formatted the way both other + // libraries format a key — see KeyFormatAgreementTests for why that is not a coincidence. + Assert.All(entries, entry => Assert.Equal(id.ToString(), entry.Str("entityId"))); + } + + /// + /// GET /api/audit/{id} — one entry, read by the id the page carries, with its changes. + /// + [RequiresDockerFact] + public async Task One_entry_is_readable_by_its_id() + { + var client = app.Client.AsActor(); + var id = await client.CreateProductAsync(app.SeededCategoryId, "Read by id"); + + var listed = (await Trail(client, "Product", id)).Single(); + var one = await client.GetJsonAsync($"/api/audit/{listed.Str("id")}"); + + Assert.Equal(listed.Str("id"), one.Str("id")); + Assert.Equal("Created", one.Str("action")); + Assert.Equal(Changes(listed).Keys.Order(), Changes(one).Keys.Order()); + } + + /// + /// The actor is whoever made the change, per request — not a configured name and not the one + /// reading the trail. + /// + /// This is the half of the library that the sample needed an authentication scheme for. + /// UseHttpContextActor() reads HttpContext.User, so the two writes below are + /// attributed to the two callers that made them, and a third caller reads both. + /// + [RequiresDockerFact] + public async Task Each_entry_records_the_caller_who_made_that_change() + { + var id = await app.Client.AsActor("alice@example.com") + .CreateProductAsync(app.SeededCategoryId, "Changed by two people"); + + await app.Client.AsActor("bob@example.com").SendJsonCheckedAsync( + HttpMethod.Put, $"/api/products/{id}", new + { + sku = $"TWO-{id}", + price = 7.0m, + categoryId = app.SeededCategoryId, + translations = new Dictionary>() + }); + + var entries = await Trail(app.Client.AsActor("carol@example.com"), "Product", id); + + Assert.Equal(["bob@example.com", "alice@example.com"], entries.Select(e => e.Str("actor"))); + } + + /// + /// A write that only changed translated text records nothing, and the model says why. + /// + /// Name and Description carry [AuditIgnore] because their value + /// depends on the language and an entry has no language — pins + /// the attributes, and this pins what they mean on the wire: an editor saving a product in + /// Arabic adds no entry, rather than one that says Name changed without saying in which + /// language. + /// + /// It also reads GET /api/audit/entities/{entityName}, which reports the rule as + /// Ignore — so the model's description and the entries agree. A description that listed + /// a property the trail never carries would be worse than none. + /// + [RequiresDockerFact] + public async Task Translated_text_is_out_of_the_trail_rather_than_in_it_without_a_language() + { + var client = app.Client.AsActor(); + var id = await client.CreateProductAsync(app.SeededCategoryId, "Text only"); + + var before = (await Trail(client, "Product", id)).Count; + + // Same columns, new text in two languages. + await client.SendJsonCheckedAsync(HttpMethod.Put, $"/api/products/{id}", new + { + sku = $"TXT-{id}", + price = 1.0m, + categoryId = app.SeededCategoryId, + translations = new Dictionary> + { + ["en"] = new() { ["Name"] = "Renamed", ["Description"] = "Rewritten." }, + ["ar"] = new() { ["Name"] = "مُعاد التسمية", ["Description"] = "أُعيدت كتابته." } + } + }); + + var after = await Trail(client, "Product", id); + + // The SKU moved, so there IS an entry — and it says only that. + Assert.Equal(before + 1, after.Count); + Assert.Equal(["Sku"], Changes(after[0]).Keys); + + // The text did reach the API, which is what makes the absence above a decision rather than + // a dropped write. Read off the detail response's per-language map, which is every + // language at once — the shape the editor draws a box per language from. + Assert.Equal( + "مُعاد التسمية", + (await client.GetJsonAsync($"/api/products/{id}")) + .GetProperty("translations").GetProperty("ar").Str("Name")); + + var described = await client.GetJsonAsync("/api/audit/entities/Product"); + + Assert.Equal( + ["Description", "Name"], + described.GetProperty("properties").EnumerateArray() + .Where(p => p.Str("rule") == "Ignore") + .Select(p => p.Str("name")) + .Order()); + } + + /// + /// A refused save takes its entry with it — the atomicity the explicit recording path was + /// arranged to keep. + /// + /// The update is recorded before the save, and the EF Core store tracks the + /// entry rather than writing it, so both are in one SaveChanges. Here that save is + /// refused by the unique index on the SKU: the product keeps its old SKU, the caller gets a + /// 409, and the trail must not have gained an entry claiming a change that never + /// happened. + /// + /// This is the test that would fail if someone "simplified" the slices by recording after + /// the save instead of before it, and nothing else in the suite would notice. + /// + [RequiresDockerFact] + public async Task A_refused_write_records_nothing() + { + var client = app.Client.AsActor(); + + var taken = $"TAKEN-{Guid.NewGuid():N}"[..12]; + await client.CreateProductAsync(app.SeededCategoryId, "Holds the sku"); + await client.SendJsonCheckedAsync(HttpMethod.Post, "/api/products", new + { + sku = taken, + price = 1.0m, + categoryId = app.SeededCategoryId, + translations = new Dictionary>() + }); + + var id = await client.CreateProductAsync(app.SeededCategoryId, "Wants the sku"); + var before = (await Trail(client, "Product", id)).Count; + + var refused = await client.SendJsonAsync(HttpMethod.Put, $"/api/products/{id}", new + { + sku = taken, + price = 99.0m, + categoryId = app.SeededCategoryId, + translations = new Dictionary>() + }); + + Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode); + Assert.Equal(before, (await Trail(client, "Product", id)).Count); + } + + /// + /// The trail is not readable without a caller — the one endpoint set in this application that + /// requires one. + /// + /// That is DotNetBoost.Auditing's own default and the reason this sample has an + /// authentication scheme at all. The three sibling libraries map their endpoints anonymously + /// and leave securing them to the application; this one does not, because a trail says who did + /// what and when. Pinned here so that "we turned it off to get the tests passing" would be a + /// failing test rather than a quiet edit. + /// + [RequiresDockerFact] + public async Task The_trail_is_the_one_thing_here_that_refuses_an_anonymous_caller() + { + // No X-Sample-Actor header. + var anonymous = app.Client; + + Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/audit")).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/audit/entities")).StatusCode); + + // While everything else is reachable exactly as before. + Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync("/api/products")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync("/api/settings")).StatusCode); + + // And with a caller, the trail answers. + Assert.Equal(HttpStatusCode.OK, (await app.Client.AsActor().GetAsync("/api/audit")).StatusCode); + } + + /// + /// GET /api/audit/entities — the two entities this application declares, and no library's + /// own. + /// + /// Which is the honest shape of this installation, and worth asserting rather than + /// explaining: Setting, MediaFile and Translation are rows in the same + /// database, and they are not in the trail, because the change-tracker capture that + /// would have recorded them cannot be attached to a context that holds a composite-key entity. + /// A reader who assumes "one DbContext, so everything is audited" has it wrong, and this is + /// where they find out. + /// + /// It also publishes maxPageSize, so a client can stop asking for more than it + /// will ever be given rather than discovering the clamp by comparing take with what it + /// requested. + /// + [RequiresDockerFact] + public async Task The_declared_entities_are_this_applications_own_two() + { + var entities = await app.Client.AsActor().GetJsonAsync("/api/audit/entities"); + + Assert.Equal( + ["Category", "Product"], + entities.GetProperty("entities").EnumerateArray().Select(e => e.Str("name")).Order()); + + Assert.All( + entities.GetProperty("entities").EnumerateArray(), + entity => Assert.True(entity.GetProperty("audited").GetBoolean())); + + Assert.True(entities.GetProperty("maxPageSize").GetInt32() > 0); + } + + // ── Reading the trail ──────────────────────────────────────────────────── + + /// One entity's entries, newest first. + private static async Task> Trail(HttpClient client, string entityName, int entityId) => + [.. (await client.GetJsonAsync($"/api/audit?entityName={entityName}&entityId={entityId}")) + .GetProperty("entries").EnumerateArray()]; + + /// An entry's changes, by property name — which is how every assertion above asks. + private static Dictionary Changes(JsonElement entry) => + entry.GetProperty("changes").EnumerateArray() + .ToDictionary(change => change.Str("propertyName"), change => change); +} + +/// +/// +/// Run in the mode the sample ships in: every language is a translation row. +/// +[Collection(SampleAppCollection.Name)] +public sealed class AuditTrailTestsInDefaultMode(SampleApp app) : AuditTrailTests(app); + +/// +/// The same assertions against the same application booted with +/// MultiLingualOptions.BaseLanguage = "en". +/// +/// Worth the second run for one reason in particular: with a base language configured, +/// Name and Description are columns of the row being audited rather than translation +/// rows — so the diff that feeds an entry genuinely sees them move, and [AuditIgnore] is +/// what keeps them out. In the other mode they could have been absent by accident. +/// +[Collection(BaseLanguageSampleAppCollection.Name)] +public sealed class AuditTrailTestsInBaseLanguageMode(BaseLanguageSampleApp app) : AuditTrailTests(app); diff --git a/tests/DotNetBoostSample.Tests/Integration/Http.cs b/tests/DotNetBoostSample.Tests/Integration/Http.cs index a55b7dd..4309023 100644 --- a/tests/DotNetBoostSample.Tests/Integration/Http.cs +++ b/tests/DotNetBoostSample.Tests/Integration/Http.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using DotNetBoostSample.Api.Authentication; using System.Net.Http.Json; using System.Text.Json; @@ -18,6 +19,31 @@ internal static class Http { private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + /// + /// The default caller, for the one endpoint set in this application that requires one. + /// + /// A name rather than a credential: the sample's authentication scheme trusts a header, + /// which is all the audit engine needs to record who acted. See + /// Api/Authentication/SampleActorAuthentication.cs — it verifies nothing, and says so + /// at length. + /// + public const string DefaultActor = "alice@example.com"; + + /// + /// The same client, naming who is asking — so a write is attributed and the trail is readable. + /// + /// Set as a default header rather than per request because every call a test makes after + /// this is the same person acting. SampleApp.Client hands out a fresh client each time, + /// so this cannot leak into another test. + /// + public static HttpClient AsActor(this HttpClient client, string actor = DefaultActor) + { + client.DefaultRequestHeaders.Remove(SampleActorAuthenticationHandler.ActorHeader); + client.DefaultRequestHeaders.Add(SampleActorAuthenticationHandler.ActorHeader, actor); + + return client; + } + /// A GET, read in — the header the whole sample turns on. public static Task GetAsync( this HttpClient client, string url, string? language = null) => diff --git a/tests/DotNetBoostSample.Tests/Integration/OpenApiShapeTests.cs b/tests/DotNetBoostSample.Tests/Integration/OpenApiShapeTests.cs index 68ba9db..28d315e 100644 --- a/tests/DotNetBoostSample.Tests/Integration/OpenApiShapeTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/OpenApiShapeTests.cs @@ -95,12 +95,17 @@ public async Task The_documented_branding_request_is_the_stored_group_alone() /// the sentence in the README saying the suite calls every one of these. That was /// established by listing this document and crossing it against what the tests request, which /// is how RemainingRouteTests found the six nothing had called — and a cross-check done - /// once stays true only until the next route is mapped. These come from four places: this - /// application's slices, and three libraries' own endpoints resolved from nuget.org. So the + /// once stays true only until the next route is mapped. These come from five places: this + /// application's slices, and four libraries' own endpoints resolved from nuget.org. So the /// surface can grow without a line of this repository changing, and the claim would go quietly /// stale rather than loudly. /// - /// Why the list rather than the count. 41 on its own survives one route being + /// Which is precisely what installing a fourth library did: the four /api/audit + /// routes arrived as a failure of this test, naming each one, and AuditTrailTests is + /// where they are now called. Nothing else in the repository had to change for them to appear. + /// + /// + /// Why the list rather than the count. 45 on its own survives one route being /// swapped for another, which is precisely the change that leaves a route uncalled while the /// number still reads true. Listed, the failure names the route — call it in a test and add it /// here, or delete the line and correct the README. @@ -109,7 +114,7 @@ public async Task The_documented_branding_request_is_the_stored_group_alone() /// base-language mode makes about the HTTP surface is that it does not touch it. /// [RequiresDockerFact] - public async Task The_published_routes_are_the_forty_one_the_suite_was_crossed_against() + public async Task The_published_routes_are_the_forty_five_the_suite_was_crossed_against() { var document = await app.Client.GetJsonAsync("/openapi/v1.json"); @@ -127,7 +132,7 @@ from operation in path.Value.EnumerateObject() } /// - /// The 41, in the order puts them — which groups them by + /// The 45, in the order puts them — which groups them by /// method, so a new route lands where it can be read against its neighbours. /// private static readonly string[] Published = @@ -140,6 +145,14 @@ from operation in path.Value.EnumerateObject() "DELETE /api/products/{id}", "DELETE /api/translations/{entityName}/{entityId}", + // The audit trail. Read-only, and the only routes here that require an authenticated + // caller — DotNetBoost.Auditing's own default, kept. The erasure and prune endpoints the + // library can also map are deliberately absent: see Program.cs. + "GET /api/audit", + "GET /api/audit/entities", + "GET /api/audit/entities/{entityName}", + "GET /api/audit/{id}", + "GET /api/categories", "GET /api/categories/{id}", "GET /api/languages", diff --git a/tests/DotNetBoostSample.Tests/Integration/RemainingRouteTests.cs b/tests/DotNetBoostSample.Tests/Integration/RemainingRouteTests.cs index 7ec6905..d34a178 100644 --- a/tests/DotNetBoostSample.Tests/Integration/RemainingRouteTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/RemainingRouteTests.cs @@ -8,12 +8,12 @@ namespace DotNetBoostSample.Tests.Integration; /// /// Why they are collected rather than distributed. They were found by listing the /// generated OpenAPI document and crossing it against what the tests actually request — six of -/// forty-one, each one reachable from the dashboard or from a client reading the document, and none -/// of them asserted anywhere. A test per route is worth more than a tidy home for it, and together +/// forty-five, each one reachable from the dashboard or from a client reading the document, and +/// none of them asserted anywhere. A test per route is worth more than a tidy home for it, and together /// they are the difference between "the suite is green" and "every published route has been /// called". /// -/// And the forty-one is pinned rather than remembered. A cross-check done by hand +/// And the forty-five is pinned rather than remembered. A cross-check done by hand /// holds only until the next route is mapped — by this application or by the next version of a /// library whose endpoints it publishes. OpenApiShapeTests asserts the inventory by name, so /// a new route arrives as a failing test with that route in the message, which is the prompt to diff --git a/tests/DotNetBoostSample.Tests/KeyFormatAgreementTests.cs b/tests/DotNetBoostSample.Tests/KeyFormatAgreementTests.cs index 8e87520..fde0089 100644 --- a/tests/DotNetBoostSample.Tests/KeyFormatAgreementTests.cs +++ b/tests/DotNetBoostSample.Tests/KeyFormatAgreementTests.cs @@ -3,6 +3,9 @@ using DotNetBoost.Media.Core.Models; using DotNetBoost.MultiLingual.Core.Metadata; using DotNetBoostSample.Application.Composition; +using DotNetBoostSample.Domain.Catalog; +using AuditingKey = DotNetBoost.Auditing.Core.Metadata.EntityKey; +using AuditEntityNames = DotNetBoost.Auditing.Core.Metadata.AuditEntityNames; using MediaKey = DotNetBoost.Media.Core.Metadata.EntityKey; using MultiLingualKey = DotNetBoost.MultiLingual.Core.Metadata.EntityKey; @@ -10,16 +13,24 @@ namespace DotNetBoostSample.Tests; /// /// Integration/MediaText.cs joins a media row to translation rows by addressing the same subject -/// in both stores. That only works while the two libraries agree on how a subject is addressed — -/// and nothing in either library enforces the agreement: each ships its own EntityKey. +/// in both stores. That only works while the libraries agree on how a subject is addressed — and +/// nothing in any of them enforces the agreement: each ships its own EntityKey. /// -/// So the agreement is asserted here, in the application that depends on it. Both libraries -/// publish the spec version they implement as EntityKey.AddressingVersion, and both bump it +/// So the agreement is asserted here, in the application that depends on it. Each library +/// publishes the spec version it implements as EntityKey.AddressingVersion, and bumps it /// for any change to name resolution, key formatting or either length limit — so one line /// covers what used to be a hand-written theory table over key formats, and covers the changes a /// table would have missed. /// /// +/// Three libraries now, not two. DotNetBoost.Auditing implements the same spec — DotNetBoost +/// Entity Addressing v1 — and its own documentation suggests exactly the assertion below. It +/// matters here for a reason the other two do not have: an audit entry is addressed by +/// (EntityName, EntityId) and by nothing else, with no foreign key back to the row, so an +/// addressing change that nobody noticed would not break a join — it would quietly produce a trail +/// that no later read can find. +/// +/// /// The two format checks below are kept on purpose. The version is a promise; they are what catches /// a release that changes the format and forgets to bump it. /// @@ -41,6 +52,30 @@ public void Both_libraries_implement_the_same_entity_addressing_version() // deriving from them, which is a promise no consumer can take up today. Hence the // constants, which are public and are enough. Assert.Equal(MultiLingualKey.AddressingVersion, MediaKey.AddressingVersion); + Assert.Equal(MultiLingualKey.AddressingVersion, AuditingKey.AddressingVersion); + } + + /// + /// And the same three libraries resolve this application's own entity to the same NAME. + /// + /// The version covers how a name is resolved; it cannot cover the fact that all three are + /// being asked about the same type. A product's trail is filed under + /// AuditEntityNames.For(typeof(Product)), its images under Media's answer and its text + /// under MultiLingual's — so if one of them ever resolved Product differently, the three + /// records of one product would stop sitting beside each other with nothing failing. + /// + /// Asserted for Product rather than for a type of this test's own, because that is + /// the type the three actually share here. + /// + [Fact] + public void All_three_libraries_file_a_product_under_the_same_entity_name() + { + Assert.Equal(nameof(Product), AuditEntityNames.For(typeof(Product))); + Assert.Equal(AuditEntityNames.For(typeof(Product)), MediaEntityNames.For(typeof(Product))); + + Assert.Equal( + AuditEntityNames.For(typeof(Product)), + MultiLingualMetadata.EntityNameFor(typeof(Product))); } [Fact] @@ -53,6 +88,7 @@ public void A_guid_is_formatted_the_same_way_by_both_libraries_and_is_still_hyph var id = Guid.NewGuid(); Assert.Equal(MultiLingualKey.ToKeyString(id), MediaKey.ToKeyString(id)); + Assert.Equal(MultiLingualKey.ToKeyString(id), AuditingKey.ToKeyString(id)); Assert.Equal(id.ToString("D"), MediaKey.ToKeyString(id)); } @@ -62,6 +98,7 @@ public void An_int_is_formatted_the_same_way_by_both_libraries() // The other key type this application addresses owners by — a Category's and a Product's // Id. Invariant decimal in both, so a culture with another digit shape cannot reach a key. Assert.Equal(MultiLingualKey.ToKeyString(42), MediaKey.ToKeyString(42)); + Assert.Equal(MultiLingualKey.ToKeyString(42), AuditingKey.ToKeyString(42)); Assert.Equal("42", MediaKey.ToKeyString(42)); } diff --git a/tests/DotNetBoostSample.Tests/ZeroCouplingTests.cs b/tests/DotNetBoostSample.Tests/ZeroCouplingTests.cs index 2d92525..dcf794e 100644 --- a/tests/DotNetBoostSample.Tests/ZeroCouplingTests.cs +++ b/tests/DotNetBoostSample.Tests/ZeroCouplingTests.cs @@ -1,5 +1,6 @@ using Xunit; using System.Reflection; +using DotNetBoost.Auditing.Core.Interfaces; using DotNetBoost.Media.Core.Interfaces; using DotNetBoost.MultiLingual.Core.Interfaces; using DotNetBoost.Settings.Core.Interfaces; @@ -7,19 +8,29 @@ namespace DotNetBoostSample.Tests; /// -/// The sample's central claim, as tests rather than as a sentence in a README: the three +/// The sample's central claim, as tests rather than as a sentence in a README: the four /// libraries are independent, and the only thing joining them is this application's own code. /// /// A README decays silently. A failing build does not. /// +/// +/// The fourth assembly is the one that makes this worth re-reading. DotNetBoost.Auditing was added +/// to a solution the other three were already in, and the assertions below did not change — no new +/// package reference appeared between any of them, and no shared kernel arrived with it. What DID +/// change is in Infrastructure/DependencyInjection.cs: independence is not free, and this +/// installation pays for it by recording its own changes because two of these libraries cannot +/// both watch one DbContext. Independence asserted here; its price written down there. +/// /// public class ZeroCouplingTests { private static readonly Assembly SettingsCore = typeof(ISettingManager).Assembly; private static readonly Assembly MediaCore = typeof(IMediaManager).Assembly; private static readonly Assembly MultiLingualCore = typeof(IMultiLingualManager).Assembly; + private static readonly Assembly AuditingCore = typeof(IAuditManager).Assembly; - private static readonly Assembly[] All = [SettingsCore, MediaCore, MultiLingualCore]; + private static readonly Assembly[] All = + [SettingsCore, MediaCore, MultiLingualCore, AuditingCore]; [Fact] public void No_library_references_another_library() @@ -64,14 +75,21 @@ public void No_library_references_a_shared_dotnetboost_kernel() [Fact] public void Each_library_brings_its_own_entity_addressing_rather_than_a_shared_one() { - // Media and MultiLingual each declare their own IEntityKeyAccessor, in their own - // namespace. Two interfaces, same name, same job — the duplication that pays for the - // independence the tests above assert. Recorded here so the trade-off is visible. - var mediaAccessor = MediaCore.GetType("DotNetBoost.Media.Core.Interfaces.IEntityKeyAccessor"); - var mlAccessor = MultiLingualCore.GetType("DotNetBoost.MultiLingual.Core.Interfaces.IEntityKeyAccessor"); + // Media, MultiLingual and Auditing each declare their own IEntityKeyAccessor, in their own + // namespace. Three interfaces, same name, same job — the duplication that pays for the + // independence the tests above assert, and it grew by one with the fourth library rather + // than being consolidated. Recorded here so the trade-off stays visible. + // + // Three copies of a contract is the cost; KeyFormatAgreementTests is why it is affordable, + // since all three implement one published addressing spec and say which version. + var accessors = new[] + { + MediaCore.GetType("DotNetBoost.Media.Core.Interfaces.IEntityKeyAccessor"), + MultiLingualCore.GetType("DotNetBoost.MultiLingual.Core.Interfaces.IEntityKeyAccessor"), + AuditingCore.GetType("DotNetBoost.Auditing.Core.Interfaces.IEntityKeyAccessor") + }; - Assert.NotNull(mediaAccessor); - Assert.NotNull(mlAccessor); - Assert.NotEqual(mediaAccessor, mlAccessor); + Assert.DoesNotContain(null, accessors); + Assert.Equal(accessors.Length, accessors.Distinct().Count()); } } From 028cfadaa6584662a9bd7cc8a9fb0112c6fcabb6 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Sat, 24 Oct 2026 19:11:53 +0300 Subject: [PATCH 02/44] Move Aspire to 13.6.4, which answers the MessagePack advisory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dotnet build DotNetBoostSample.slnx` has been failing at restore, on main and on every branch cut from it: aspire/DotNetBoostSample.AppHost.csproj : error NU1902: Warning As Error: Package 'MessagePack' 2.5.302 has a known moderate severity vulnerability Nothing in this repository changed to cause that. GHSA-qhrr-8q5h-9q3h (CVE-2026-92707, moderate) was published after the last commit landed green, and NuGet's audit reads the advisory database at restore time rather than at commit time — so the same source that built yesterday stops building today, and the first failure arrives on a branch that did not touch a package. MessagePack is not referenced here and should not be. It reached the AppHost transitively, Aspire.Hosting.AppHost 13.5.3 -> StreamJsonRpc 2.25.29 -> MessagePack 2.5.302, and the advisory is fixed in 2.5.303. Aspire 13.6.2 was the first release to depend on MessagePack 2.5.307 on its own account; that dependency sits one hop from the AppHost where StreamJsonRpc's sits two, so it is the one resolution picks, and the advisory is answered by a version number this repository already states rather than by a direct PackageReference to a library only Aspire calls. 13.6.4 is the current patch of that line. So the three Aspire.Hosting.* pins move together — they are one product — and the Aspire.AppHost.Sdk version in the AppHost csproj moves with them, since the SDK generates the `Projects.*` handles the hosting packages consume and a split between the two is not a combination anyone tests. The reason for the number is written down beside it, including the 13.6.2 floor, so that a later tidy-up does not quietly walk back into the advisory. What this is not: no NuGetAuditMode change, no NoWarn, no edit to TreatWarningsAsErrors, and the audit still runs for every project in the solution. The fix is a fixed version, not a silenced warning. AppHost.cs needed no changes; nothing it calls moved across the minor. The two documents that named the old version — the multi-targeting caveat in ARCHITECTURE-SLICES.md and the matching paragraph in the README — say 13.6. Verified: the solution builds in Release with 0 errors, MessagePack resolves to 2.5.307, and the suite is 121 passed / 0 skipped with a real PostgreSQL. `aspire run` brings up postgres, api and dashboard — /health answers Healthy, /api/categories serves the seeded catalogue, and the dashboard proxies it. The build is not warning-free, and this commit does not make it so: it emits six `Source control information is not available` warnings from SourceLink. Those are pre-existing — building main's solution with the audit gate relaxed purely to measure gives the identical six — and they are MSBuild-task warnings with no code, which is why TreatWarningsAsErrors, a compiler property, never made them errors. They are left alone here rather than folded into an unrelated fix. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE-SLICES.md | 2 +- Directory.Packages.props | 21 ++++++++++++++++--- README.md | 2 +- .../DotNetBoostSample.AppHost.csproj | 7 +++++-- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE-SLICES.md b/ARCHITECTURE-SLICES.md index 6708eda..9d223df 100644 --- a/ARCHITECTURE-SLICES.md +++ b/ARCHITECTURE-SLICES.md @@ -46,7 +46,7 @@ Three reasons specific to this repo, not general taste: **And a caveat on multi-targeting.** The *libraries* below can target `net8.0;net10.0`. The **host cannot today**: `Microsoft.EntityFrameworkCore` / `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3 and -Aspire 13.5.3 all require `net10.0`. §7 has the conditional-version pattern if you want the host on +Aspire 13.6.4 all require `net10.0`. §7 has the conditional-version pattern if you want the host on both too; the value is mostly in the class libraries, where multi-targeting proves the slices compile against DotNetBoost's own `net8.0` TFM. diff --git a/Directory.Packages.props b/Directory.Packages.props index ed003db..d3f947d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -77,10 +77,25 @@ + - - - + + + diff --git a/README.md b/README.md index b962c3e..07f59b1 100644 --- a/README.md +++ b/README.md @@ -828,7 +828,7 @@ depending on all three because it composes them. The three class libraries multi-target `net8.0;net10.0`, matching the TFMs the DotNetBoost packages themselves ship, so the application code is proved against both. The host cannot follow: Aspire -13.5 and the Npgsql EF Core 10 provider require `net10.0`. EF Core is therefore pinned per +13.6 and the Npgsql EF Core 10 provider require `net10.0`. EF Core is therefore pinned per framework in `Directory.Packages.props` — 8.0.30 and 10.0.11, the versions the DotNetBoost EF packages ask for on each, because naming a lower one is a package downgrade and an error. diff --git a/aspire/DotNetBoostSample.AppHost/DotNetBoostSample.AppHost.csproj b/aspire/DotNetBoostSample.AppHost/DotNetBoostSample.AppHost.csproj index 8ee0e8a..7483a35 100644 --- a/aspire/DotNetBoostSample.AppHost/DotNetBoostSample.AppHost.csproj +++ b/aspire/DotNetBoostSample.AppHost/DotNetBoostSample.AppHost.csproj @@ -1,8 +1,11 @@ - + `Projects.*` handles used by AddProject() and the `aspire run` entry point. + + Version-locked to the Aspire.Hosting.* packages in Directory.Packages.props, which is + where the reason for this particular number is written down. --> + net10.0 From b86de06ceb03d2b6239e22551dead41e7b9c51be Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Sat, 24 Oct 2026 19:43:03 +0300 Subject: [PATCH 03/44] Read the trail from the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth library was the only one with no page, and the README said so in as many words. It is here now, and the sentence is gone. Two things made it unlike the other three pages. The audit endpoints require an authenticated caller — the library's own default, which this sample keeps — and a browser sending no header cannot read a single entry. And every change made through this dashboard was being recorded with no actor, so the trail answered "what changed" and "unknown" to "who changed it": half a library, demonstrated. So the dashboard asks who you are. "Acting as", at the foot of the sidebar, is a name you type; useApi sends it as X-Sample-Actor on every call it makes, which makes the trail readable and the catalogue's own writes attributable in one stroke. Nothing is invented on anybody's behalf: acting as nobody is the starting state, those changes are still recorded with no actor, and the page renders a null actor as "unknown" and never as "system". The Nitro proxy is unchanged — it forwards the request's headers as they arrive, as it always has for Accept-Language — and now says why adding a name of its own was the wrong answer: the dashboard knows nobody, so a forwarded name could only be a made-up one, in the trail of every change anyone makes. The page follows the media browser. Every filter — entity, key, actor, action, date range — is a parameter on the query the API is asked, never a pass over the rows already on screen, which for an append-only trail searches the wrong set. The entity list is GET api/audit/entities' answer rather than a list written into the client, and the page bound is the maxPageSize that same response publishes. Paging is newer/older over the API's own nextCursor: no skip, which the API refuses alongside a cursor anyway, and no total, which this read deliberately never asks for. Picking an entity also draws what the MODEL says is recorded for it, which is where [AuditIgnore] on Name and Description stops looking like a trail that has quietly stopped working. test/nuxt/useAuditTrail.spec.ts pins four things, three of which were wrong in the first cut: a cursor outliving the filter that produced it (the API is then handed a position in a trail nobody is reading); a filter typed mid-flight being answered with the page already out, because the entry is registered dedupe: 'defer' and every deliberate move has to refresh with 'cancel'; the declared entities read under two keys; and — the one the page hangs off — the actor header actually leaving the browser, since one that does not looks exactly like a server that is down. Two things found on the way. `take` in a page of the trail is how many entries came back and not the page size asked for, so "is there an older page" cannot be read off it; the comparison is against the size this page asked for, and it matters because nextCursor is returned for every non-empty page, the last one included. And a bare @ in a vue-i18n message is that library's linked-message syntax: an email-shaped placeholder has to be written alice{'@'}example.com or the bundle does not compile at all. @nuxt/test-utils moves 4.3.3 → 4.3.4, inside the range package.json already allowed. 4.3.3 against the Vite 8 that nuxt 4.6.1 brings in fails to load every suite — "Failed to resolve import ./diagnostics/core.js" — so npm test was red before any of this. Verified with npm ci under node:22-alpine, the npm the runner has. 139 .NET tests still pass, DocumentedCountTests over the rewritten README among them; lint, typecheck and the eight vitest tests pass; and the page was driven against a live API in English and in Arabic, where a category created from the catalogue page came back in the trail as dana@example.com with Slug and CreatedAt moved and Name nowhere in sight. Co-Authored-By: Claude Opus 5 --- README.md | 88 ++++- clients/dashboard/.nuxtrc | 2 +- clients/dashboard/app/components/ActingAs.vue | 108 ++++++ .../app/components/audit/EntityRules.vue | 115 ++++++ .../app/components/audit/EntryChanges.vue | 138 ++++++++ .../app/components/audit/Filters.vue | 205 +++++++++++ clients/dashboard/app/composables/useApi.ts | 116 ++++++- .../app/composables/useAuditTrail.ts | 303 ++++++++++++++++ clients/dashboard/app/layouts/default.vue | 12 + clients/dashboard/app/pages/audit.vue | 286 +++++++++++++++ clients/dashboard/app/plugins/acting-as.ts | 26 ++ clients/dashboard/app/types/index.d.ts | 181 ++++++++++ .../dashboard/app/utils/rememberedActor.ts | 39 +++ clients/dashboard/i18n/locales/ar.json | 95 +++++ clients/dashboard/i18n/locales/en.json | 95 +++++ clients/dashboard/i18n/locales/fr.json | 95 +++++ clients/dashboard/package-lock.json | 16 +- clients/dashboard/package.json | 2 +- clients/dashboard/server/api/[...path].ts | 31 +- .../dashboard/test/nuxt/useAuditTrail.spec.ts | 326 ++++++++++++++++++ 20 files changed, 2252 insertions(+), 27 deletions(-) create mode 100644 clients/dashboard/app/components/ActingAs.vue create mode 100644 clients/dashboard/app/components/audit/EntityRules.vue create mode 100644 clients/dashboard/app/components/audit/EntryChanges.vue create mode 100644 clients/dashboard/app/components/audit/Filters.vue create mode 100644 clients/dashboard/app/composables/useAuditTrail.ts create mode 100644 clients/dashboard/app/pages/audit.vue create mode 100644 clients/dashboard/app/plugins/acting-as.ts create mode 100644 clients/dashboard/app/utils/rememberedActor.ts create mode 100644 clients/dashboard/test/nuxt/useAuditTrail.spec.ts diff --git a/README.md b/README.md index e615d0f..ab9a47f 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ because they are the point. ![The overview page: four counts, and the three files where the libraries meet](docs/screenshots/overview.png) -A sidebar shell matching the three libraries' own dashboards — **Catalog**, **Media**, -**Languages**, **Settings**, with the reading-language selector in every page header. +A sidebar shell matching the four libraries' own dashboards — **Catalog**, **Media**, +**Languages**, **Audit**, **Settings**, with the reading-language selector in every page header +and, at the foot of the sidebar, who the dashboard is **acting as**. That one selector moves **both** languages in the dashboard. The content language is a row in DotNetBoost.MultiLingual that the API resolves from `Accept-Language`; the dashboard's own labels, @@ -179,10 +180,43 @@ on the write's own response, in the language the list is being read in. A catego products is refused in the same language. [What a catalogue write answers with](#what-a-catalogue-write-answers-with) has the shapes. -**There is no Audit page.** The trail is API-only for now — `GET /api/audit`, behind the one -authorization check in this application — and the dashboard's sidebar still has the four pages -above. A screen that reads it is the obvious next thing and is not here yet, which is said plainly -rather than implied by a missing tab. +**Audit** is DotNetBoost.Auditing's trail, and the one page here that has to be signed in to. +`MapAuditEndpoints()` requires an authenticated caller *by default* — a trail says who did what and +when — and this sample keeps that default rather than switching it off, so with nobody set the page +explains the `401` instead of showing an empty table. + +Who you are is the control at the foot of the sidebar: **acting as**. It is a name you type, sent as +`X-Sample-Actor` on every call the dashboard makes — which is what makes your own catalogue writes +attributable as well as the trail readable. The dashboard knows nobody, so nothing is invented +there: acting as nobody is the starting state, changes made that way are recorded with no actor, and +the trail renders that as `unknown` rather than naming a person who does not exist. The API trusts +the header outright and [says so at +length](src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs); the dashboard's half +of the bargain is to send only a name somebody typed. + +The filters — entity, key, actor, action and a date range — are parameters on the query the API is +asked, never a pass over the rows already on screen, which for an append-only trail would search the +wrong set. The entity list comes from `GET /api/audit/entities`, so it is this installation's own +two and not a list written into the client, and the page size is bounded by the `maxPageSize` the +same response publishes rather than by a number the dashboard would have to keep in step by hand. +Paging is **newer and older** rather than a page count: the walk follows the `nextCursor` the API +hands back, which is what the library asks a client to prefer over an offset, and the read +deliberately never asks for a total — so there is no number to page against, which is the honest +shape of a cursor walk. + +An entry expands to the properties that moved, old value → new. Two of those readings are not +obvious from the data and are worth seeing done: a **redacted** change arrives as two nulls, so it +is drawn as "not disclosed" rather than as a blank, which would say the field was cleared; and a +**null actor** is drawn as `unknown`, never as "system", because nothing was captured and the entry +names nobody. + +Pick an entity and the page also shows **what is recorded for it**, from +`GET /api/audit/entities/{entityName}` — the model's own description rather than a summary of the +entries, so it answers before the trail has a row in it. `Name` and `Description` are reported as +`Ignore`, which is where someone who edited a product's Arabic text and saw no new entry finds out +why: a translated value depends on the language and an entry has no language. [A translated property +is not in the trail](#7-a-translated-property-is-not-in-the-trail) is the same claim from the other +side. ## Run it @@ -238,10 +272,9 @@ curl -H 'X-Sample-Actor: alice@example.com' \ 'http://localhost:5180/api/audit?entityName=Product&take=10' ``` -Change a product in the dashboard first and the entry will say `alice@example.com` did it only if -the dashboard sent that header; it does not, so changes made through the UI are recorded with no -actor — honestly, rather than as "system". Send the header with a `curl` write and the next entry -carries the name. +The dashboard sends the same header, from the **acting as** control at the foot of its sidebar — so +a product changed there is attributed to whatever name is set, and to no actor at all while none is. +Either way the trail is read on the Audit page as well as with `curl`. **The dashboard** needs **Node 22+**, its dependencies installed — `aspire run` does that for you and nothing else does — and the address of that API: @@ -967,8 +1000,8 @@ OrbStack or Podman to run the sample at all, so this adds no prerequisite. cd clients/dashboard && npm test ``` -Vitest, in Nuxt's test environment, over the composables the pages are assembled from. Two suites, -and each exists because of a bug `dotnet test` could not have caught. +Vitest, in Nuxt's test environment, over the composables the pages are assembled from. Three +suites, and each exists because of a bug `dotnet test` could not have caught. The first: attaching a logo on the branding tab left the role box showing the file that had just been replaced. The images it draws @@ -995,6 +1028,18 @@ that a language created afterwards reaches the two that never asked again. The f failed when it was written — three readers, three requests — which is how `dedupe: 'defer'` got there. +`test/nuxt/useAuditTrail.spec.ts` is the third, and its bug is the one a cursor walk invites: the +cursor outlived the filter. A cursor is a position in one ordered, filtered trail, so kept across a +filter change it was sent with the new filter and addressed a place in a trail that was no longer +being read — the API answered with the middle of a set nobody had asked for, or with nothing, which +reads as "no entries match" for a filter that matches plenty. Two more properties are pinned beside +it: that `skip` is never sent at all, which the API refuses together with `after` anyway; and that +a filter typed while the first page is still in flight is not answered with that page — the entry is +registered `dedupe: 'defer'` for the readers that arrive in the same tick, so every deliberate move +refreshes with `dedupe: 'cancel'` instead. The fourth test is the one the whole page hangs off: +without the actor header the trail is a `401`, and naming somebody makes it readable — a header that +never left the browser looks exactly like a server that is down. + The specs live under `test/nuxt/` because Nuxt's generated tsconfig includes that path, so `npm run typecheck` covers them. Anywhere else they run and are silently not typechecked — which they were, briefly, and which hid an incomplete fixture in the very first one. @@ -1218,6 +1263,25 @@ clients/dashboard/ Nuxt 4 + Nuxt UI, sidebar shell, RTL-aw app/composables/useCatalogSettings.ts the same group, read-only, for the screens it drives app/utils/settings.ts one icon per route, and the label lookups — the group list itself comes from the API + app/components/ActingAs.vue who the dashboard is acting as — a name you type, + sent as X-Sample-Actor on every call. No default and + nothing invented: acting as nobody is a state + app/plugins/acting-as.ts restores that name before the first request and + remembers it when it changes + app/utils/rememberedActor.ts the name across visits — not a credential, and + remembering it is not "staying signed in" + app/pages/audit.vue Auditing's trail: the filters, the walk, and the + two readings that are not in the data — a redacted + change is "not disclosed", a null actor is unknown + app/components/audit/Filters.vue entity, key, actor, action, dates, page size — every + one of them a parameter on the query the API is asked + app/components/audit/EntryChanges.vue one entry's properties, old value → new + app/components/audit/EntityRules.vue what the MODEL says is recorded for one entity, from + GET api/audit/entities/{entityName} — which is where + [AuditIgnore] on the translated text becomes visible + app/composables/useAuditTrail.ts one listing, walked by the API's own nextCursor and + never by an offset — and the cursor is dropped + whenever the question changes app/pages/languages.vue MultiLingual's own languages table app/components/languages/FormModal.vue add or edit a language app/composables/useLanguages.ts create, update, set-default, delete, purge diff --git a/clients/dashboard/.nuxtrc b/clients/dashboard/.nuxtrc index 6415c6e..472fb3f 100644 --- a/clients/dashboard/.nuxtrc +++ b/clients/dashboard/.nuxtrc @@ -1 +1 @@ -setups.@nuxt/test-utils="4.3.3" \ No newline at end of file +setups.@nuxt/test-utils="4.3.4" \ No newline at end of file diff --git a/clients/dashboard/app/components/ActingAs.vue b/clients/dashboard/app/components/ActingAs.vue new file mode 100644 index 0000000..0ff2d66 --- /dev/null +++ b/clients/dashboard/app/components/ActingAs.vue @@ -0,0 +1,108 @@ + + + diff --git a/clients/dashboard/app/components/audit/EntityRules.vue b/clients/dashboard/app/components/audit/EntityRules.vue new file mode 100644 index 0000000..e4ba325 --- /dev/null +++ b/clients/dashboard/app/components/audit/EntityRules.vue @@ -0,0 +1,115 @@ + + + diff --git a/clients/dashboard/app/components/audit/EntryChanges.vue b/clients/dashboard/app/components/audit/EntryChanges.vue new file mode 100644 index 0000000..d9343ad --- /dev/null +++ b/clients/dashboard/app/components/audit/EntryChanges.vue @@ -0,0 +1,138 @@ + + + diff --git a/clients/dashboard/app/components/audit/Filters.vue b/clients/dashboard/app/components/audit/Filters.vue new file mode 100644 index 0000000..b3808a7 --- /dev/null +++ b/clients/dashboard/app/components/audit/Filters.vue @@ -0,0 +1,205 @@ + + + diff --git a/clients/dashboard/app/composables/useApi.ts b/clients/dashboard/app/composables/useApi.ts index beec270..0425520 100644 --- a/clients/dashboard/app/composables/useApi.ts +++ b/clients/dashboard/app/composables/useApi.ts @@ -1,14 +1,18 @@ import type { - Category, CategoryDetail, CreateResult, Language, MediaOwner, MediaTextValues, ProductDetail, - ProductPage, SettingGroupDescriptor, SettingGroupSchema, Translations, WriteResult + AuditEntities, AuditEntitySchema, AuditEntry, AuditPage, AuditQuery, Category, CategoryDetail, + CreateResult, Language, MediaOwner, MediaTextValues, ProductDetail, ProductPage, + SettingGroupDescriptor, SettingGroupSchema, Translations, WriteResult } from '~/types' /** - * This application's own endpoints, plus the two library endpoints the catalogue pages need. + * This application's own endpoints, plus the library endpoints its pages need. * * The media library is NOT wrapped here: `useMediaLibrary` talks to `/api/media` directly, exactly * as the DotNetBoost.Media dashboard does, so searching, filtering, sorting, paging, uploading, * attaching and detaching are that library's own behaviour rather than a second implementation. + * Those calls therefore carry neither header, which costs nothing: a media file is not an audited + * entity here — `GET api/audit/entities` lists this application's two — so there is no entry for + * an actor to be missing from. */ /** @@ -36,6 +40,30 @@ const CAPTION = 'Caption' */ export const useLanguage = () => useState('language', () => 'en') +/** + * Who the dashboard is acting as. Sent as `X-Sample-Actor` on every call that goes through here, + * and empty for "nobody", which is a request with no such header at all. + * + * Two things in this sample depend on it, and they are the two halves of what DotNetBoost.Auditing + * does: + * + * • **the audit endpoints require an authenticated caller** — the library's own default, which + * this sample deliberately keeps — so with no name the Audit page gets a `401` and says why; + * • **a change records whoever made it**. `UseHttpContextActor()` reads `HttpContext.User`, so a + * catalogue write that carries this header is attributed to that name, and one that does not + * is recorded with no actor rather than with a guess. + * + * **This is not authentication and must not be read as any.** The API's scheme trusts the header + * outright — anyone who can reach it can claim to be anyone — and + * `Authentication/SampleActorAuthentication.cs` is where that is set out in full. The dashboard's + * side of the bargain is to send only a name a person typed into `ActingAs.vue`: no default, no + * `dashboard@localhost`, nothing invented here and presented to the trail as an identity. + * + * Seeded empty and overwritten by `plugins/acting-as.ts` with whatever was last used, before the + * app mounts. + */ +export const useActor = () => useState('actor', () => '') + /** * MultiLingual's translation route for a subject, from the two strings the API published for it. * Encoded, because an entity name is a value and not a path fragment this client chose. @@ -59,7 +87,22 @@ export interface Abortable { export function useApi() { const language = useLanguage() - const headers = () => ({ 'Accept-Language': language.value }) + const actor = useActor() + + /** + * The two headers every call below carries: the language to answer in, and who is asking. + * + * The actor is omitted rather than sent empty when nobody is set. A `X-Sample-Actor:` with no + * value is not an anonymous request to a header-trusting scheme — it is a request to be + * authenticated as the empty string — and the API's handler is written to read it as anonymous + * either way, which is exactly the kind of agreement that should not need to hold. + * + * Both travel through the Nitro proxy untouched; `server/api/[...path].ts` says why. + */ + const headers = (): Record => ({ + 'Accept-Language': language.value, + ...(actor.value ? { 'X-Sample-Actor': actor.value } : {}) + }) return { // ── DotNetBoost.MultiLingual ────────────────────────────────────────── @@ -96,6 +139,71 @@ export function useApi() { }) }, + // ── DotNetBoost.Auditing ────────────────────────────────────────────── + // + // Read-only, because this application maps only the library's read endpoints: no erasure, no + // prune. Every one of them requires an authenticated caller — the library's own default, kept — + // so all four go out with the actor header `headers()` adds, and answer `401` without it. + audit: { + /** + * One page of the trail, newest first. + * + * `after` rather than `skip`: an offset makes the database read and discard everything + * before the page, and an entry arriving mid-walk shifts a row across the boundary. The API + * refuses both at once with a `400`, which is why nothing here ever sends `skip`. + * + * `includeChanges` is asked for with the page. The changes are what a row of this trail is + * *for* — the alternative is a request per row the moment one is opened — and `includeTotal` + * is deliberately not asked for: a cursor walk cannot use a total, and counting a trail is + * the expensive half of reading one. + * + * Empty strings are left out rather than sent: to this API a blank parameter is an absent + * filter, and relying on that would mean relying on it staying true. + */ + query: ( + query: AuditQuery, + page: { after?: string, from?: string, to?: string }, + { signal }: Abortable = {} + ) => $fetch('/api/audit', { + query: { + entityName: query.entityName || undefined, + entityId: query.entityId || undefined, + actor: query.actor || undefined, + action: query.action || undefined, + correlationId: query.correlationId || undefined, + from: page.from, + to: page.to, + take: query.pageSize, + after: page.after, + includeChanges: true + }, + headers: headers(), + signal + }), + + /** One entry by its id, with its changes. */ + get: (id: string, { signal }: Abortable = {}) => + $fetch(`/api/audit/${id}`, { headers: headers(), signal }), + + /** + * What can be filtered by, and `maxPageSize` — the bound every read is subject to, published + * so that a client does not have to hard-code a number that must then be kept in step with + * the server's by hand. + */ + entities: ({ signal }: Abortable = {}) => + $fetch('/api/audit/entities', { headers: headers(), signal }), + + /** + * One entity's properties and the rule each falls under. + * + * Encoded, because a stored entity name is a value and not a path fragment this client + * chose — the same reason `translationsUrl` encodes its two. + */ + describe: (entityName: string, { signal }: Abortable = {}) => + $fetch( + `/api/audit/entities/${encodeURIComponent(entityName)}`, { headers: headers(), signal }) + }, + // ── This application: the catalogue ─────────────────────────────────── // // Accept-Language on the writes as well as the reads. A write's own answer is now a sentence — diff --git a/clients/dashboard/app/composables/useAuditTrail.ts b/clients/dashboard/app/composables/useAuditTrail.ts new file mode 100644 index 0000000..4cf07df --- /dev/null +++ b/clients/dashboard/app/composables/useAuditTrail.ts @@ -0,0 +1,303 @@ +import type { AuditEntities, AuditEntitySchema, AuditPage, AuditQuery } from '~/types' + +/** + * The key both readers of the declared entities share, so the dashboard asks for them once. + */ +export const AUDIT_ENTITIES_KEY = 'audit-entities' + +/** The key the trail listing is read under. One page of one trail at a time, by design. */ +export const AUDIT_TRAIL_KEY = 'audit-trail' + +/** The key the selected entity's rules are read under. See {@link useAuditTrail}. */ +export const AUDIT_ENTITY_KEY = 'audit-entity' + +/** + * What the trail can be filtered by, from `GET api/audit/entities`, plus the page bound every read + * is subject to. + * + * One `useAsyncData` key, shared: the filter bar builds its entity list from this and the rules + * card checks `audited` against it, and neither should cost a request of its own. The same + * arrangement as `useLanguageList`, for the same reason — Nuxt de-duplicates by key alone and will + * not re-run an entry that has already resolved, so two keys over one endpoint are two answers + * that can drift apart. + * + * `dedupe: 'defer'` likewise: the readers register in the same tick, and under the default + * `cancel` each arrival would abort the request in flight and issue its own. + */ +export function useAuditEntities() { + const api = useApi() + + const asyncData = useAsyncData( + AUDIT_ENTITIES_KEY, + (_nuxtApp, { signal }) => api.audit.entities({ signal }), + { dedupe: 'defer' }) + + const { data, error, refresh } = asyncData + + /** Never null — the page reads straight off it before the first answer lands. */ + const entities = computed(() => data.value?.entities ?? []) + + /** + * The largest page the API will serve, read rather than assumed. + * + * `0` until it has answered, which is how the page-size control knows not to offer a size yet. + * Hard-coding a cap here would be a number to keep in step with the server's by hand, and a + * client that asks for more than the cap is not refused — it is clamped, and only `take` in the + * response says so. + */ + const maxPageSize = computed(() => data.value?.maxPageSize ?? 0) + + return { entities, maxPageSize, error, refresh } +} + +/** + * One reading of the audit trail: the filter, the page, the walk, and the rules for whichever + * entity is selected. + * + * **Every filter lives in the query the API is asked.** Nothing here narrows rows already on + * screen — the same rule the media browser follows, and more load-bearing here: a trail is + * append-only and only grows, so the page you are looking at is never the set you meant to search. + * + * **The walk is by cursor, not by offset.** `nextCursor` is the token the API hands back for the + * next page; `skip` is never sent, because an offset makes the database read and discard everything + * ahead of the page and shifts a row across the boundary whenever an entry arrives mid-walk. The + * API refuses both at once with a `400`, so "send the cursor and also the offset" is not a mistake + * that can hide. + * + * **Three keys, one per resource.** The listing, the declared entities, and the selected entity's + * rules are three endpoints; `useAuditEntities` is shared with the filter bar, and the other two + * belong to this page. What is deliberately NOT here is a second key for the same listing read a + * second way, which is the bug `useLanguageList` documents. + * + * **Not a shared composable.** Like `useMediaLibrary`, this is one listing over one filter. There + * is one Audit page, and if a second screen ever read the trail it would be reading its own + * question, not this one. + */ +export function useAuditTrail() { + const api = useApi() + const actor = useActor() + const { entities, maxPageSize, error: entitiesError, refresh: refreshEntities } = useAuditEntities() + + const query = reactive({ + entityName: '', + entityId: '', + actor: '', + action: '', + from: '', + to: '', + correlationId: '', + pageSize: 25 + }) + + /** + * The cursor that opened each page past the first, so "newer" is a pop rather than a second walk + * from the top. + * + * A cursor walk is one-directional — the API hands back where a page ended and nothing else — so + * going back means remembering where you came from. Emptied by every change to the question + * below; see {@link setFilters}. + */ + const cursors = ref([]) + + const after = computed(() => cursors.value.at(-1)) + + /** + * The two date boxes as the instants the API is asked for. + * + * Read as whole **UTC** days: an entry's `occurredAt` is UTC and the string is sent without a + * zone designator, so there is one unambiguous reading of "the 24th" rather than one that + * depends on the time zone of whichever machine the API is running on. The rows themselves are + * drawn in local time, which `audit.filters.datesHelp` says out loud rather than leaving someone + * to notice a late-evening entry on the edge of a range. + */ + const bounds = computed(() => ({ + from: query.from ? `${query.from}T00:00:00` : undefined, + to: query.to ? `${query.to}T23:59:59.999` : undefined + })) + + const asyncData = useAsyncData( + AUDIT_TRAIL_KEY, + (_nuxtApp, { signal }) => api.audit.query(query, { after: after.value, ...bounds.value }, { signal }), + // `defer` for the arrivals nobody asked for — the page and its filter bar resolving in the + // same tick — and never for a deliberate move: every call below refreshes with + // `dedupe: 'cancel'`, because an answer already in flight is the answer to the previous + // question. Handed back under `defer`, a filter typed while the first page was still loading + // would quietly show the unfiltered trail and the box would say otherwise. + { dedupe: 'defer' }) + + const { data, pending, error, refresh } = asyncData + + /** + * One entity's properties and the rule each falls under, for whichever entity is filtered on. + * + * `null` with no entity selected: "what is recorded for Product" is not a question with an + * answer across entities, and the rules card draws nothing rather than something averaged. + * + * This is the model's own description, not a summary of the entries — which is what makes it + * worth showing at all here. `Name` and `Description` are reported as `Ignore`, so the page can + * say that translated text is deliberately out of the trail instead of leaving a reader to + * conclude the recording is broken. + */ + const schemaData = useAsyncData( + AUDIT_ENTITY_KEY, + (_nuxtApp, { signal }) => + query.entityName ? api.audit.describe(query.entityName, { signal }) : Promise.resolve(null), + { dedupe: 'defer' }) + + const entries = computed(() => data.value?.entries ?? []) + + /** Which page of the walk is on screen. 1-based, and only ever reached one page at a time. */ + const page = computed(() => cursors.value.length + 1) + + const hasNewer = computed(() => cursors.value.length > 0) + + /** + * Whether there is an older page to ask for. + * + * `nextCursor` alone is not the answer, and this is the trap in it: the API returns one for every + * page that holds anything, the last one included — it is where the page ended, never a promise + * that more exist. Six entries read twenty-five at a time come back with a cursor, and a pager + * that trusted it would offer "older" on a trail it had just shown all of. + * + * So the other half is the page being full: fewer entries than were asked for is the end of the + * walk. `take` cannot answer this — it reports how many entries came back, which is the same + * number — so the comparison is against the size this page asked for, which is never above + * `maxPageSize` because {@link setPageSize} clamps it there. + * + * A last page that is exactly full still offers "older" once, and the answer is an empty page + * with "newer" beside it. That is the honest shape of a cursor walk, and the alternative is a + * count this read deliberately never asked for. + */ + const hasOlder = computed(() => + !!data.value?.nextCursor && entries.value.length >= query.pageSize) + + const isFiltered = computed(() => !!( + query.entityName || query.entityId || query.actor || query.action + || query.from || query.to || query.correlationId)) + + /** + * Whether the API refused the read for want of a caller. + * + * The one endpoint set in this application that does — DotNetBoost.Auditing's own default, which + * the sample keeps — so a `401` here is not a fault to report as one. The page explains it and + * points at the control that fixes it. + */ + const unauthorized = computed(() => + statusOf(error.value) === 401 || statusOf(entitiesError.value) === 401) + + /** Any other refusal, in the API's own words. Empty while the trail is merely unauthorized. */ + const loadError = computed(() => + !unauthorized.value && error.value ? problemMessage(error.value) : '') + + /** Reloads the listing for a question that has just changed. Never deferred — see above. */ + const reload = () => refresh({ dedupe: 'cancel' }) + + /** + * Changes the question and starts the walk again from the newest entry. + * + * **Emptying `cursors` is the whole of it, and it is not optional.** A cursor is a position in + * one ordered, filtered trail. Kept across a filter change it addresses a place in a trail that + * is no longer being read: the API is handed `after` from the old walk together with the new + * filter, and answers with entries from the middle of a set the user never asked for — or with + * nothing at all, which reads as "no entries match" for a filter that matches plenty. + */ + function setFilters(patch: Partial) { + Object.assign(query, patch) + cursors.value = [] + + return reload() + } + + /** Asks for the next page, from where this one ended. */ + function older() { + const cursor = data.value?.nextCursor + + if (!cursor) { + return + } + + cursors.value = [...cursors.value, cursor] + + return reload() + } + + /** Back to the page before, by the cursor that opened it. */ + function newer() { + if (!hasNewer.value) { + return + } + + cursors.value = cursors.value.slice(0, -1) + + return reload() + } + + /** + * How many entries a page asks for, bounded by what the API says it will serve. + * + * Clamped here as well as on the server because a control offering a number that is then + * silently reduced is a control that lies — the response's `take` would disagree with the box. + */ + function setPageSize(size: number) { + const max = maxPageSize.value || size + + return setFilters({ pageSize: Math.max(1, Math.min(size, max)) }) + } + + /** + * Narrows the trail to one edit — every entry written under the same correlation id. + * + * Worth a control of its own because an edit in this application is not always one entry: a + * product created with text in three languages writes the product's entry and, in a scope of its + * own, the creation trail's — see `Composition/CreationTrail.cs`. The correlation id is what says + * those belong together, and reading them apart is how someone concludes the trail is recording + * twice. + * + * The other filters are cleared with it: the rest of an edit may well be another entity. + */ + function showEdit(correlationId: string) { + return setFilters({ + correlationId, + entityName: '', + entityId: '', + actor: '', + action: '', + from: '', + to: '' + }) + } + + function reset() { + return setFilters({ + entityName: '', + entityId: '', + actor: '', + action: '', + from: '', + to: '', + correlationId: '' + }) + } + + /** + * Setting a name in the sidebar makes the trail readable; clearing it makes it a `401` again. + * + * One watcher, here, rather than one in `useActor` — which every call in `useApi` reads, so a + * watcher living there would be one per caller all refreshing the same keys. This is the only + * screen whose answers change with who is asking. + */ + watch(actor, () => { + refreshEntities({ dedupe: 'cancel' }) + schemaData.refresh({ dedupe: 'cancel' }) + reload() + }) + + /** The rules follow the entity filter, and nothing else re-reads them. */ + watch(() => query.entityName, () => schemaData.refresh({ dedupe: 'cancel' })) + + return { + query, entries, entities, maxPageSize, schema: schemaData.data, + pending, page, hasNewer, hasOlder, isFiltered, unauthorized, loadError, + setFilters, setPageSize, older, newer, showEdit, reset, reload + } +} diff --git a/clients/dashboard/app/layouts/default.vue b/clients/dashboard/app/layouts/default.vue index 65d0d07..c3eeb0c 100644 --- a/clients/dashboard/app/layouts/default.vue +++ b/clients/dashboard/app/layouts/default.vue @@ -29,6 +29,11 @@ const links = computed(() => [[{ icon: 'i-lucide-languages', to: '/languages', onSelect: close +}, { + label: t('nav.audit'), + icon: 'i-lucide-history', + to: '/audit', + onSelect: close }, { label: t('nav.settings'), icon: 'i-lucide-settings', @@ -88,6 +93,13 @@ const groups = computed(() => [{ diff --git a/clients/dashboard/app/pages/audit.vue b/clients/dashboard/app/pages/audit.vue new file mode 100644 index 0000000..1b5af5f --- /dev/null +++ b/clients/dashboard/app/pages/audit.vue @@ -0,0 +1,286 @@ + + + diff --git a/clients/dashboard/app/plugins/acting-as.ts b/clients/dashboard/app/plugins/acting-as.ts new file mode 100644 index 0000000..ff4c834 --- /dev/null +++ b/clients/dashboard/app/plugins/acting-as.ts @@ -0,0 +1,26 @@ +/** + * Who the dashboard is acting as, restored before anything renders and remembered when it changes. + * + * A plugin for the same two reasons `reading-language.ts` is one: it runs once before the app + * mounts, so the first request a page makes already carries the name rather than 401-ing on the + * Audit page and recovering a tick later; and `useActor` is read by every call `useApi` makes, so a + * watcher living in it would be one watcher per caller all writing the same key. + * + * Both halves are here, unlike the language — whose direction is only known once + * `GET api/languages` has answered, which is why `app.vue` writes that one. A name needs nothing + * from the API to be worth remembering. + */ +export default defineNuxtPlugin({ + name: 'acting-as', + + setup() { + const actor = useActor() + const remembered = readRememberedActor() + + if (remembered) { + actor.value = remembered + } + + watch(actor, value => rememberActor(value.trim())) + } +}) diff --git a/clients/dashboard/app/types/index.d.ts b/clients/dashboard/app/types/index.d.ts index a1e434e..361d75c 100644 --- a/clients/dashboard/app/types/index.d.ts +++ b/clients/dashboard/app/types/index.d.ts @@ -388,3 +388,184 @@ export interface SettingGroup { */ icon: string } + +// ── DotNetBoost.Auditing ───────────────────────────────────────────────────── +// +// The four `/api/audit` routes, as the library returns them. Written out here rather than inferred +// from a response, for the same reason every other shape in this file is: the names are the +// library's, and the two that carry a meaning a renderer has to honour — `actor: null` and +// `isRedacted` — are documented where that renderer will look for them. + +/** + * What happened to the row. Serialized as its name rather than its number, so this union is the + * whole set and a renumbering on the server cannot silently turn one action into another. + */ +export type AuditAction = 'Created' | 'Updated' | 'Deleted' + +/** + * What the model says about one property: recorded with its values, recorded without them, or not + * recorded at all. + * + * `Ignore` is the one this application leans on — `Category.Name`, `Category.Description`, + * `Product.Name` and `Product.Description` carry `[AuditIgnore]`, because their value depends on + * the language and an entry has no language. + */ +export type AuditRule = 'Record' | 'Redact' | 'Ignore' + +/** One property's move, as an entry carries it. */ +export interface AuditChange { + /** As named on the class, which is how `AuditProperty.name` spells it too. */ + propertyName: string + oldValue: string | null + newValue: string | null + /** + * The values were withheld because the model marks the property sensitive — `Redact`. + * + * When this is set the two nulls mean "not disclosed" rather than "was empty", and the library's + * own documentation asks a client to say so rather than render a blank. `audit.redacted` is that + * sentence; nothing here is ever drawn as an empty cell on the strength of a null alone. + */ + isRedacted: boolean + /** Either value was cut short at the configured limit. */ + isTruncated: boolean +} + +/** One recorded change, as `GET api/audit` and `GET api/audit/{id}` return it. */ +export interface AuditEntry { + id: string + /** The entity's stored name — `Product`, not necessarily the class name. */ + entityName: string + /** That entity's key, formatted the way both other libraries format one. */ + entityId: string + action: AuditAction + /** + * Who made the change, or `null` when nothing was captured. + * + * **`null` is not a claim about anybody.** It is not "system", and the library says so in as + * many words. The page renders it as `audit.unknownActor` — "unknown" — which is the only + * honest reading of an actor that was never recorded. + */ + actor: string | null + /** Ties this entry to the others of the same edit. */ + correlationId: string | null + /** Always UTC. Drawn through `formatDate` / `formatRelative`, in the UI locale. */ + occurredAt: string + /** + * Whatever the application stored alongside the entry, as the string it stored. The library + * never looked inside it and neither does this client. + */ + metadata: string | null + /** Empty when the read did not ask for changes, and legitimately empty for some deletions. */ + changes: AuditChange[] +} + +/** One page of `GET api/audit`. */ +export interface AuditPage { + /** Newest first. */ + entries: AuditEntry[] + /** + * How many matched, or `null` when the read did not ask — which this dashboard does not. + * `null` means "not counted" and never zero, so nothing may paginate against it. + */ + total: number | null + skip: number + /** + * How many entries this page actually holds — the same number as `entries.length`, and **not** + * the page size that was asked for: a request for 50 against a trail of 6 comes back with + * `take: 6`. Read against the API, because the library documents it as the clamped page size and + * a client that trusted that would read a short page as a full one. + * + * So it cannot report the clamp either. `maxPageSize` on `GET api/audit/entities` is where the + * bound is published, which is why that endpoint is read before the first page. + */ + take: number + /** + * The token to send as `after` for the next page, or `null` when the walk is over. + * + * Not a promise that more entries exist: it is where this page ended, so the last full page + * carries one and the request after it comes back empty. Preferred to `skip` for walking a + * trail, which is why this dashboard never sends `skip` — an offset makes the database read and + * discard everything before the page, and an entry arriving between two requests shifts a row + * across the boundary. Sending both is a `400`. + */ + nextCursor: string | null +} + +/** One row of `GET api/audit/entities`. */ +export interface AuditEntityDescriptor { + /** The name entries are stored under, and the value to send as `entityName`. */ + name: string + clrType: string + /** + * Whether a change to it would be recorded **right now** — the engine's own answer, so it + * accounts for the kill switch and opt-in mode as well as for the attributes. + */ + audited: boolean + optedIn: boolean + ignored: boolean + /** + * Effective retention once a prune runs. `null` means nothing is configured either way, so a + * prune leaves its entries alone — never that they expire immediately. This sample maps no + * prune endpoint, so nothing here ever runs one. + */ + retentionDays: number | null + hasOwnRetention: boolean +} + +/** + * `GET api/audit/entities` — what can be filtered by, and the bound every read is subject to. + * + * `maxPageSize` is why this endpoint is read before the first page: a client that hard-codes a cap + * has a number to keep in step with the server's by hand, and a client that does not know the cap + * discovers it by comparing `take` with what it asked for. + */ +export interface AuditEntities { + entities: AuditEntityDescriptor[] + maxPageSize: number +} + +/** One property, as `GET api/audit/entities/{entityName}` describes it. */ +export interface AuditProperty { + name: string + /** The CLR type name — the underlying type for a nullable. */ + type: string + nullable: boolean + rule: AuditRule +} + +/** + * `GET api/audit/entities/{entityName}` — what the model says about one entity. Never anything + * recorded, which is what makes it answerable before the trail has a single entry in it. + */ +export interface AuditEntitySchema { + name: string + clrType: string + audited: boolean + retentionDays: number | null + hasOwnRetention: boolean + /** Every public instance property, the ignored ones included. */ + properties: AuditProperty[] +} + +/** + * The filter the Audit page is currently reading the trail with. + * + * Every one of these is a query parameter on `GET api/audit` rather than something applied to the + * rows already on screen — the same rule the media browser follows, and for the same reason: a + * trail is append-only and far longer than a page, so filtering what has been loaded filters the + * wrong set. + * + * `from` and `to` are `yyyy-mm-dd` as a date input produces them; `useAuditTrail` turns them into + * the instants the API is asked for. + */ +export interface AuditQuery { + entityName: string + entityId: string + actor: string + action: AuditAction | '' + from: string + to: string + /** Set by "the rest of this edit", which narrows the trail to one correlation id. */ + correlationId: string + pageSize: number +} diff --git a/clients/dashboard/app/utils/rememberedActor.ts b/clients/dashboard/app/utils/rememberedActor.ts new file mode 100644 index 0000000..3c2a6de --- /dev/null +++ b/clients/dashboard/app/utils/rememberedActor.ts @@ -0,0 +1,39 @@ +/** + * Who the dashboard was last acting as, across visits. + * + * `localStorage`, beside the reading language and for the same reason: nothing on the server wants + * it. `X-Sample-Actor` is attached per request by `useApi` from the live value — a cookie would be + * a second copy for the Nitro proxy to read and disagree with. + * + * **It is not a credential, and remembering it is not "staying signed in".** The API's scheme + * trusts the header outright; see `Authentication/SampleActorAuthentication.cs`, which says so at + * length. What is kept here is the answer to "whose name should the trail carry", so that reloading + * the page does not quietly go back to recording changes against nobody. + */ +const STORAGE_KEY = 'dotnetboost-sample:acting-as' + +/** + * Guarded like {@link readRememberedLanguage}: a private window, blocked site data and a cleared + * profile each throw or answer `null`, and none of them is a reason for the dashboard to refuse to + * open. It starts out acting as nobody, which is a state the whole app already handles. + */ +export function readRememberedActor(): string { + try { + return localStorage.getItem(STORAGE_KEY)?.trim() ?? '' + } catch { + return '' + } +} + +/** Stores the name, or forgets it when it is empty — "acting as nobody" is not a name to keep. */ +export function rememberActor(actor: string): void { + try { + if (actor) { + localStorage.setItem(STORAGE_KEY, actor) + } else { + localStorage.removeItem(STORAGE_KEY) + } + } catch { + // The choice still holds for this session; it just will not outlive it. + } +} diff --git a/clients/dashboard/i18n/locales/ar.json b/clients/dashboard/i18n/locales/ar.json index 108da7f..f023d6d 100644 --- a/clients/dashboard/i18n/locales/ar.json +++ b/clients/dashboard/i18n/locales/ar.json @@ -24,6 +24,7 @@ "catalog": "الكتالوج", "media": "الوسائط", "languages": "اللغات", + "audit": "التدقيق", "settings": "الإعدادات", "apiReference": "مرجع الـ API", "github": "DotNetBoost على GitHub", @@ -36,6 +37,19 @@ "readingLanguage": { "label": "لغة القراءة" }, + "actingAs": { + "trigger": "العمل باسم", + "nobody": "دون اسم", + "label": "الاسم", + "placeholder": "alice{'@'}example.com", + "help": "يُرسَل في ترويسة X-Sample-Actor مع كل طلب. يسجّله سجل التدقيق مع كل تغيير تجريه، وقراءة السجل تحتاجه.", + "apply": "تطبيق", + "clear": "العمل دون اسم", + "notAuthentication": { + "title": "ليست مصادقة", + "description": "تثق واجهة البرمجة بهذه الترويسة كما هي — ومن يصل إليها يستطيع أن يدّعي أي اسم. هي تنوب عن تسجيل الدخول حتى يتمكّن هذا المثال من إظهار سجل تدقيق بأسماء حقيقية." + } + }, "appearance": { "trigger": "المظهر", "theme": "السمة", @@ -246,6 +260,87 @@ } } }, + "audit": { + "title": "سجل التدقيق", + "card": { + "description": "تقدّمها MapAuditEndpoints() من DotNetBoost.Auditing.API — نقاط نهاية المكتبة نفسها، لا نقاط كتبها هذا التطبيق. كل مرشّح أدناه معامل في الاستعلام المُرسَل إلى واجهة البرمجة، والصفحات تُستعرَض بالمؤشّر الذي تعيده لا بإزاحة." + }, + "reload": "إعادة تحميل السجل", + "loadError": "تعذّر تحميل السجل", + "empty": "لم يُسجَّل شيء بعد. عدّل منتجًا أو تصنيفًا وسيظهر هنا.", + "emptyFiltered": "لا توجد مدخلات تطابق هذا المرشّح.", + "expand": "إظهار تغييرات الخصائص", + "filterToEntity": "إظهار {entity} {id} فقط", + "unknownActor": "غير معروف", + "unknownActorTooltip": "لم يُسجَّل فاعل لهذا التغيير. المدخل لا يسمّي أحدًا — وهذا ليس كأن يسمّي النظام.", + "changeCount": "لا تغييرات | تغيير واحد | تغييران | {n} تغييرات | {n} تغييرًا | {n} تغيير", + "noChanges": "لم تُسجَّل أي قيم خصائص مع هذا المدخل.", + "redacted": "غير معلنة", + "redactedTooltip": "يصف النموذج هذه الخاصية بأنها حسّاسة، فحُجبت القيم ولم تُسجَّل. هي ليست مفقودة — بل لم تُخزَّن قطّ.", + "truncated": "مقتطعة", + "truncatedTooltip": "كانت القيمة أطول من الحد المضبوط فاقتُطعت عند تسجيلها.", + "needsCaller": { + "title": "قراءة السجل تحتاج طالبًا موثَّقًا", + "description": "تتطلّب نقاط نهاية DotNetBoost.Auditing طالبًا موثَّقًا، وهذا المثال يحتفظ بهذا الإعداد الافتراضي ولا يعطّله — فالسجل يقول من فعل ماذا ومتى. اضبط اسمًا من «العمل باسم» في أسفل الشريط الجانبي؛ يُرسَل مع كل طلب، وتُسجَّل تغييراتك باسمه من تلك اللحظة. وحتى ذلك الحين تُسجَّل التغييرات التي تجري هنا بلا فاعل." + }, + "filters": { + "entity": "الكيان", + "anyEntity": "أي كيان", + "notAudited": "مُعلَن، لكن تغييراته لا تُسجَّل حاليًا.", + "entityId": "المفتاح", + "actor": "الفاعل", + "action": "الإجراء", + "anyAction": "أي إجراء", + "from": "من", + "to": "إلى", + "datesHelp": "أيام كاملة تُقرأ بالتوقيت العالمي المنسّق — السجل مخزَّن به، أما الأوقات في الجدول فبتوقيتك.", + "pageSize": "لكل صفحة" + }, + "actions": { + "created": "أُنشئ", + "updated": "عُدِّل", + "deleted": "حُذف" + }, + "columns": { + "occurredAt": "الوقت", + "entity": "الكيان", + "action": "الإجراء", + "actor": "الفاعل", + "changes": "التغييرات" + }, + "changes": { + "property": "الخاصية", + "oldValue": "قبل", + "newValue": "بعد" + }, + "entry": { + "id": "المدخل", + "occurredAt": "سُجِّل", + "metadata": "بيانات وصفية", + "correlationId": "التعديل", + "showEdit": "بقية هذا التعديل" + }, + "editFilter": { + "title": "تعديل واحد", + "description": "كل المدخلات المكتوبة بالمعرّف الرابط نفسه. والتعديل ليس دائمًا مدخلًا واحدًا: إنشاء منتج بنص في عدة لغات يكتب مدخل المنتج ومدخل سجل الإنشاء، في نطاق خاص به." + }, + "pager": { + "page": "صفحة {n}", + "newer": "الأحدث", + "older": "الأقدم" + }, + "rules": { + "title": "ما يُسجَّل لـ {entity}", + "description": "ما يقوله النموذج عن هذا الصنف — وليس ملخصًا للمدخلات، فهو يُجيب قبل أن يحتوي السجل مدخلًا واحدًا.", + "notAudited": "لا يُسجَّل", + "record": "تُسجَّل مع قيمها", + "redact": "تُسجَّل دون قيمها", + "ignore": "لا تُسجَّل أبدًا — القيمة المترجمة تتوقف على اللغة، والمدخل لا لغة له", + "retentionOwn": "تُحفظ بلا مدة — بإعدادها الخاص | تُحفظ يومًا واحدًا — بإعدادها الخاص | تُحفظ يومين — بإعدادها الخاص | تُحفظ {n} أيام — بإعدادها الخاص | تُحفظ {n} يومًا — بإعدادها الخاص | تُحفظ {n} يوم — بإعدادها الخاص", + "retentionDefault": "تُحفظ بلا مدة — بالإعداد الافتراضي | تُحفظ يومًا واحدًا — بالإعداد الافتراضي | تُحفظ يومين — بالإعداد الافتراضي | تُحفظ {n} أيام — بالإعداد الافتراضي | تُحفظ {n} يومًا — بالإعداد الافتراضي | تُحفظ {n} يوم — بالإعداد الافتراضي", + "retentionNone": "تُحفظ بلا حد" + } + }, "media": { "page": { "title": "كل ملف مرفوع", diff --git a/clients/dashboard/i18n/locales/en.json b/clients/dashboard/i18n/locales/en.json index 533d9a8..1c9b3b6 100644 --- a/clients/dashboard/i18n/locales/en.json +++ b/clients/dashboard/i18n/locales/en.json @@ -24,6 +24,7 @@ "catalog": "Catalog", "media": "Media", "languages": "Languages", + "audit": "Audit", "settings": "Settings", "apiReference": "API reference", "github": "DotNetBoost on GitHub", @@ -36,6 +37,19 @@ "readingLanguage": { "label": "Reading language" }, + "actingAs": { + "trigger": "Acting as", + "nobody": "Acting as nobody", + "label": "Name", + "placeholder": "alice{'@'}example.com", + "help": "Sent as X-Sample-Actor on every call. The audit trail records it against each change you make, and reading the trail needs it.", + "apply": "Apply", + "clear": "Act as nobody", + "notAuthentication": { + "title": "Not authentication", + "description": "The API trusts this header outright — anyone who can reach it can claim to be anyone. It stands in for a sign-in so the sample can show a trail with real names in it." + } + }, "appearance": { "trigger": "Appearance", "theme": "Theme", @@ -246,6 +260,87 @@ } } }, + "audit": { + "title": "Audit trail", + "card": { + "description": "Served by MapAuditEndpoints() from DotNetBoost.Auditing.API — library endpoints, not ones this application wrote. Every filter below is a parameter on the query the API is asked, and the pages are walked with the cursor it hands back rather than with an offset." + }, + "reload": "Reload the trail", + "loadError": "Could not load the trail", + "empty": "Nothing has been recorded yet. Change a product or a category and it will be here.", + "emptyFiltered": "No entries match this filter.", + "expand": "Show the property changes", + "filterToEntity": "Show only {entity} {id}", + "unknownActor": "unknown", + "unknownActorTooltip": "No actor was captured for this change. The entry names nobody — which is not the same as naming the system.", + "changeCount": "{n} change | {n} changes", + "noChanges": "No property values were recorded with this entry.", + "redacted": "not disclosed", + "redactedTooltip": "The model marks this property sensitive, so the values were withheld rather than recorded. They are not missing — they were never stored.", + "truncated": "truncated", + "truncatedTooltip": "A value was longer than the configured limit and was cut short when it was recorded.", + "needsCaller": { + "title": "Reading the trail needs a caller", + "description": "DotNetBoost.Auditing's endpoints require an authenticated caller, and this sample keeps that default rather than turning it off — a trail says who did what and when. Set a name under \"Acting as\" at the bottom of the sidebar; it is sent on every call, and your own changes are recorded against it from then on. Until then, changes made here are recorded with no actor at all." + }, + "filters": { + "entity": "Entity", + "anyEntity": "Any entity", + "notAudited": "Declared, but changes to it are not being recorded right now.", + "entityId": "Key", + "actor": "Actor", + "action": "Action", + "anyAction": "Any action", + "from": "From", + "to": "To", + "datesHelp": "Whole days, read in UTC — the trail is stored in UTC, while the times in the table are your own.", + "pageSize": "Per page" + }, + "actions": { + "created": "Created", + "updated": "Updated", + "deleted": "Deleted" + }, + "columns": { + "occurredAt": "When", + "entity": "Entity", + "action": "Action", + "actor": "Actor", + "changes": "Changes" + }, + "changes": { + "property": "Property", + "oldValue": "Before", + "newValue": "After" + }, + "entry": { + "id": "Entry", + "occurredAt": "Recorded", + "metadata": "Metadata", + "correlationId": "Edit", + "showEdit": "The rest of this edit" + }, + "editFilter": { + "title": "One edit", + "description": "Every entry written under the same correlation id. An edit is not always one entry: creating a product with text in several languages writes the product's own entry and the creation trail's, in a scope of its own." + }, + "pager": { + "page": "Page {n}", + "newer": "Newer", + "older": "Older" + }, + "rules": { + "title": "What is recorded for {entity}", + "description": "What the model says about this class — not a summary of the entries, so it answers before the trail has a single row in it.", + "notAudited": "not recorded", + "record": "recorded with their values", + "redact": "recorded without them", + "ignore": "never recorded — a translated value depends on the language, and an entry has no language", + "retentionOwn": "kept {n} day — its own | kept {n} days — its own", + "retentionDefault": "kept {n} day — the configured default | kept {n} days — the configured default", + "retentionNone": "kept indefinitely" + } + }, "media": { "page": { "title": "Every uploaded file", diff --git a/clients/dashboard/i18n/locales/fr.json b/clients/dashboard/i18n/locales/fr.json index b9cde85..7525979 100644 --- a/clients/dashboard/i18n/locales/fr.json +++ b/clients/dashboard/i18n/locales/fr.json @@ -24,6 +24,7 @@ "catalog": "Catalogue", "media": "Médias", "languages": "Langues", + "audit": "Audit", "settings": "Paramètres", "apiReference": "Référence de l'API", "github": "DotNetBoost sur GitHub", @@ -36,6 +37,19 @@ "readingLanguage": { "label": "Langue de lecture" }, + "actingAs": { + "trigger": "Agir en tant que", + "nobody": "Aucun nom", + "label": "Nom", + "placeholder": "alice{'@'}example.com", + "help": "Envoyé dans l'en-tête X-Sample-Actor à chaque appel. Le journal d'audit l'enregistre avec chacune de vos modifications, et sa lecture l'exige.", + "apply": "Appliquer", + "clear": "N'agir sous aucun nom", + "notAuthentication": { + "title": "Ce n'est pas une authentification", + "description": "L'API fait confiance à cet en-tête tel quel — quiconque peut l'atteindre peut se prétendre n'importe qui. Il tient lieu de connexion pour que cet exemple puisse montrer un journal portant de vrais noms." + } + }, "appearance": { "trigger": "Apparence", "theme": "Thème", @@ -246,6 +260,87 @@ } } }, + "audit": { + "title": "Journal d'audit", + "card": { + "description": "Servi par MapAuditEndpoints() de DotNetBoost.Auditing.API — les points de terminaison de la bibliothèque, pas ceux de cette application. Chaque filtre ci-dessous est un paramètre de la requête envoyée à l'API, et les pages se parcourent avec le curseur qu'elle renvoie plutôt qu'avec un décalage." + }, + "reload": "Recharger le journal", + "loadError": "Impossible de charger le journal", + "empty": "Rien n'a encore été enregistré. Modifiez un produit ou une catégorie et l'entrée apparaîtra ici.", + "emptyFiltered": "Aucune entrée ne correspond à ce filtre.", + "expand": "Afficher les propriétés modifiées", + "filterToEntity": "N'afficher que {entity} {id}", + "unknownActor": "inconnu", + "unknownActorTooltip": "Aucun acteur n'a été capturé pour cette modification. L'entrée ne nomme personne — ce qui n'est pas la même chose que nommer le système.", + "changeCount": "{n} modification | {n} modifications", + "noChanges": "Aucune valeur de propriété n'a été enregistrée avec cette entrée.", + "redacted": "non divulguée", + "redactedTooltip": "Le modèle marque cette propriété comme sensible : les valeurs ont été retenues au lieu d'être enregistrées. Elles ne manquent pas — elles n'ont jamais été stockées.", + "truncated": "tronquée", + "truncatedTooltip": "Une valeur dépassait la limite configurée et a été coupée à l'enregistrement.", + "needsCaller": { + "title": "La lecture du journal exige un appelant", + "description": "Les points de terminaison de DotNetBoost.Auditing exigent un appelant authentifié, et cet exemple conserve ce comportement par défaut au lieu de le désactiver — un journal dit qui a fait quoi et quand. Définissez un nom sous « Agir en tant que », en bas de la barre latérale ; il est envoyé à chaque appel, et vos modifications sont dès lors enregistrées sous ce nom. D'ici là, les modifications faites ici sont enregistrées sans aucun acteur." + }, + "filters": { + "entity": "Entité", + "anyEntity": "Toute entité", + "notAudited": "Déclarée, mais ses modifications ne sont pas enregistrées actuellement.", + "entityId": "Clé", + "actor": "Acteur", + "action": "Action", + "anyAction": "Toute action", + "from": "Du", + "to": "Au", + "datesHelp": "Des journées entières, lues en UTC — le journal est stocké en UTC, tandis que les heures du tableau sont les vôtres.", + "pageSize": "Par page" + }, + "actions": { + "created": "Créé", + "updated": "Modifié", + "deleted": "Supprimé" + }, + "columns": { + "occurredAt": "Quand", + "entity": "Entité", + "action": "Action", + "actor": "Acteur", + "changes": "Modifications" + }, + "changes": { + "property": "Propriété", + "oldValue": "Avant", + "newValue": "Après" + }, + "entry": { + "id": "Entrée", + "occurredAt": "Enregistrée", + "metadata": "Métadonnées", + "correlationId": "Édition", + "showEdit": "Le reste de cette édition" + }, + "editFilter": { + "title": "Une seule édition", + "description": "Toutes les entrées écrites sous le même identifiant de corrélation. Une édition n'est pas toujours une seule entrée : créer un produit avec du texte en plusieurs langues écrit l'entrée du produit et celle du journal de création, dans une portée qui lui est propre." + }, + "pager": { + "page": "Page {n}", + "newer": "Plus récentes", + "older": "Plus anciennes" + }, + "rules": { + "title": "Ce qui est enregistré pour {entity}", + "description": "Ce que dit le modèle de cette classe — et non un résumé des entrées : la réponse arrive avant que le journal ne contienne la moindre ligne.", + "notAudited": "non enregistrée", + "record": "enregistrées avec leurs valeurs", + "redact": "enregistrées sans leurs valeurs", + "ignore": "jamais enregistrées — une valeur traduite dépend de la langue, et une entrée n'a pas de langue", + "retentionOwn": "conservées {n} jour — sa propre règle | conservées {n} jours — sa propre règle", + "retentionDefault": "conservées {n} jour — la valeur par défaut configurée | conservées {n} jours — la valeur par défaut configurée", + "retentionNone": "conservées sans limite" + } + }, "media": { "page": { "title": "Tous les fichiers téléversés", diff --git a/clients/dashboard/package-lock.json b/clients/dashboard/package-lock.json index b3e249c..5e211df 100644 --- a/clients/dashboard/package-lock.json +++ b/clients/dashboard/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@nuxt/eslint": "^1.17.0", - "@nuxt/test-utils": "^4.3.3", + "@nuxt/test-utils": "^4.3.4", "@vue/test-utils": "^2.5.1", "eslint": "^10.9.0", "h3": "^1.15.11", @@ -3110,28 +3110,28 @@ } }, "node_modules/@nuxt/test-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-4.3.3.tgz", - "integrity": "sha512-0Nxp6e+VJROAMGuyurTX3+ON+l3y79TuNDsciy+7TfCPx4GcitTrYsxjomi65jLVi0H32aEb2sloh/qmiwj6aA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-4.3.4.tgz", + "integrity": "sha512-8x0w16DDUi1/6ZivL1zuOaDt9xtACZFHMQySBirUZNIU0H0qljjB5yyajXPtlb8YCQ5l6piFJqafK/zDpDcCnA==", "dev": true, "license": "MIT", "dependencies": { "@clack/prompts": "1.8.1", "@nuxt/devtools-kit": "^3.4.2", - "@nuxt/kit": "^4.5.2", + "@nuxt/kit": "^4.6.1", "consola": "^3.4.2", "defu": "^6.1.7", - "exsolve": "^1.1.1", + "exsolve": "^1.1.3", "fake-indexeddb": "^6.2.5", "get-port-please": "^3.2.0", "magic-string": "^1.4.2", "ofetch": "^1.5.1", "oxc-walker": "^1.1.1", - "package-manager-detector": "^1.8.0", + "package-manager-detector": "^1.9.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "radix3": "^1.1.2", - "std-env": "^4.2.0", + "std-env": "^4.3.0", "tinyexec": "^1.3.1", "ufo": "^1.6.4", "unplugin": "^3.4.0", diff --git a/clients/dashboard/package.json b/clients/dashboard/package.json index caf5576..913e30c 100644 --- a/clients/dashboard/package.json +++ b/clients/dashboard/package.json @@ -24,7 +24,7 @@ }, "devDependencies": { "@nuxt/eslint": "^1.17.0", - "@nuxt/test-utils": "^4.3.3", + "@nuxt/test-utils": "^4.3.4", "@vue/test-utils": "^2.5.1", "eslint": "^10.9.0", "h3": "^1.15.11", diff --git a/clients/dashboard/server/api/[...path].ts b/clients/dashboard/server/api/[...path].ts index 8d900fe..83f9c24 100644 --- a/clients/dashboard/server/api/[...path].ts +++ b/clients/dashboard/server/api/[...path].ts @@ -1,10 +1,39 @@ /** - * Proxies every `/api/**` call through to the sample API — the three libraries' endpoints and the + * Proxies every `/api/**` call through to the sample API — the four libraries' endpoints and the * application's own alike, which is why all of them live under `/api` on the server. * * The browser therefore only ever talks to this app's own origin, and the target moves between * environments by changing `NUXT_API_URL` alone. An upload streams through Nitro rather than * being buffered here: `proxyRequest` forwards the request body as it arrives. + * + * ## Who the API thinks is calling + * + * DotNetBoost.Auditing's endpoints require an authenticated caller — the library's own default, + * which this sample keeps — and the API's stand-in scheme reads the caller's name off + * `X-Sample-Actor`. So something had to decide what the browser sends. There were two honest + * answers, and this one is the second: + * + * • **the proxy could add the header itself**, forwarding one name the dashboard knows about. It + * is one line here and it is rejected: the dashboard does not know about anybody. The name + * would have to be made up — `dashboard@localhost`, say — and every entry in the trail would + * then carry an identity nobody has, which is worse than carrying none. It would also attribute + * every visitor's changes to the same invented person, which is the one thing an audit trail + * must not do; + * • **the dashboard asks who you are** and sends that. `ActingAs.vue` in the sidebar is the whole + * of it, `useApi` attaches the answer to every call, and acting as nobody is both the starting + * state and a state the UI handles: catalogue writes are then recorded with no actor — honestly + * — and the Audit page explains the `401` it gets instead of pretending the trail is empty. + * + * **Nothing is added, removed or relaxed here.** `proxyRequest` forwards the request's headers as + * they arrived, minus the hop-by-hop ones h3 strips (`connection`, `host`, `accept-encoding` and + * friends), so `X-Sample-Actor` and `Accept-Language` reach the API exactly as the browser sent + * them — which is also how the reading language has always travelled. A proxy that rewrote either + * would be a second opinion about who is calling, in the one place nobody would think to look for + * one. + * + * That the header is a poor authenticator is the API's business and the API says so at length, in + * `src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs`. What matters on this + * side is that the dashboard never claims to be somebody on a person's behalf. */ export default defineEventHandler(async (event) => { const { apiUrl } = useRuntimeConfig(event) diff --git a/clients/dashboard/test/nuxt/useAuditTrail.spec.ts b/clients/dashboard/test/nuxt/useAuditTrail.spec.ts new file mode 100644 index 0000000..36ca402 --- /dev/null +++ b/clients/dashboard/test/nuxt/useAuditTrail.spec.ts @@ -0,0 +1,326 @@ +import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' +import { enableAutoUnmount } from '@vue/test-utils' +import { createError, defineEventHandler, getHeader, getQuery } from 'h3' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import type { AuditEntry } from '~/types' + +/** + * How the Audit page asks the trail its question, and what happens when the question changes. + * + * Why this file exists. Three of the four properties below were wrong in a first cut of + * `useAuditTrail`, and none of them is visible from inside one request: + * + *
    + *
  • the cursor outlived the filter. A cursor is a position in one ordered, filtered + * trail. Kept across a filter change it addresses a place in a trail that is no longer being + * read, so the API was handed `after` from the old walk together with the new filter — and + * answered with entries from the middle of a set nobody asked for, or with nothing at all, which + * reads as "no entries match" for a filter that matches plenty;
  • + * + *
  • the second question was answered with the first one's page. The entry is registered + * with `dedupe: 'defer'`, which is right for the readers that arrive in the same tick and wrong + * for every deliberate move: typing an actor while the first page was still in flight handed back + * the pending promise, so the box said `bob@example.com` over the unfiltered trail. Every move + * refreshes with `dedupe: 'cancel'` for that reason;
  • + * + *
  • the declared entities were read twice — once for the filter bar's entity list and + * once for the page bound — which is the bug `useLanguageList.spec.ts` documents at length, + * arriving a second time on a second endpoint.
  • + *
+ * + * The fourth is the one this whole page hangs off: the actor header has to actually be sent. + * The audit endpoints are the only ones in this application that refuse an anonymous caller, so a + * header that never left the browser looks exactly like a server that is down. + */ + +/** The trail the fake endpoint serves, newest first — the order the API answers in. */ +const TRAIL: AuditEntry[] = [ + { + id: 'e3', + entityName: 'Product', + entityId: '7', + action: 'Updated', + actor: 'bob@example.com', + correlationId: 'c3', + occurredAt: '2026-10-24T09:00:00Z', + metadata: null, + changes: [{ + propertyName: 'Price', oldValue: '1.00', newValue: '42.50', + isRedacted: false, isTruncated: false + }] + }, + { + id: 'e2', + entityName: 'Product', + entityId: '7', + action: 'Created', + actor: 'alice@example.com', + correlationId: 'c2', + occurredAt: '2026-10-23T09:00:00Z', + metadata: null, + changes: [] + }, + { + id: 'e1', + entityName: 'Category', + entityId: '2', + action: 'Created', + actor: null, + correlationId: 'c1', + occurredAt: '2026-10-22T09:00:00Z', + metadata: null, + changes: [] + } +] + +/** Every query the trail endpoint was asked, in order. Most of what is under test. */ +let reads: Record[] +/** How many times the declared entities were asked for, which is the other half. */ +let entityReads: number +/** Held closed by the one test that asks a second question while the first is still in flight. */ +let gate: Promise | null + +/** + * `GET api/audit`, close enough to the real one where it matters: it refuses an anonymous caller, + * it filters by actor, it pages by cursor, and it refuses `skip` and `after` together the way the + * API does — which is what makes "never send `skip`" an assertion rather than a convention. + * + * The cursor is the last entry's id. The real tokens are opaque, and nothing in the composable may + * read one, which is exactly why a stand-in can stand in. + */ +registerEndpoint('/api/audit', defineEventHandler(async (event) => { + if (!getHeader(event, 'x-sample-actor')) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + + const query = getQuery(event) as Record + reads.push(query) + + if (query.skip !== undefined && query.after !== undefined) { + throw createError({ statusCode: 400, statusMessage: 'skip and after are mutually exclusive' }) + } + + if (gate) { + await gate + } + + const take = Number(query.take ?? 25) + const matching = TRAIL.filter(entry => !query.actor || entry.actor === query.actor) + const start = query.after ? matching.findIndex(entry => entry.id === query.after) + 1 : 0 + const entries = matching.slice(start, start + take) + + return { + entries, + // Never counted: the dashboard does not ask for a total, and `null` is what the API answers + // when it has not been asked. A client that paginated against it would be paginating against + // "not counted". + total: null, + skip: 0, + // How many came back, which is what the real endpoint reports — not the size that was asked + // for. Mimicked because a client that reads it as the page size reads a short page as a full + // one, and the pager's "is there more" hangs off exactly that. + take: entries.length, + // Returned for every page that holds anything, the last one included. It says where this page + // ended and never that another exists, which is the other half of the same trap. + nextCursor: entries.length ? entries[entries.length - 1]!.id : null + } +})) + +registerEndpoint('/api/audit/entities', defineEventHandler((event) => { + if (!getHeader(event, 'x-sample-actor')) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + + entityReads++ + + return { + entities: [ + { name: 'Category', clrType: 'Category', audited: true, optedIn: false, ignored: false, retentionDays: null, hasOwnRetention: false }, + { name: 'Product', clrType: 'Product', audited: true, optedIn: false, ignored: false, retentionDays: null, hasOwnRetention: false } + ], + maxPageSize: 50 + } +})) + +/** + * Mounts something that reads the trail, acting as `actor` — set before the composable runs, so the + * first request carries it, exactly as `plugins/acting-as.ts` arranges in the app. + */ +async function mountTrail(actor: string) { + let trail!: ReturnType + let acting!: Ref + + await mountSuspended(defineComponent({ + setup() { + acting = useActor() + acting.value = actor + trail = useAuditTrail() + + return () => h('div') + } + })) + + return { trail, acting } +} + +/** A second reader of the declared entities, the way the filter bar is one. */ +async function mountEntities() { + let entities!: ReturnType + + await mountSuspended(defineComponent({ + setup() { + entities = useAuditEntities() + + return () => h('div') + } + })) + + return entities +} + +/** + * The two halves of starting each test from nothing, and both are needed. + * + * A mounted reader outlives its test — the file is one Nuxt app — so without the unmount, the + * previous test's component is still a reader of the same `useAsyncData` key: its own `query`, with + * its own page size, answers over the top of the one under test and the failure reads as a + * composable that ignored a filter. And a resolved entry is never re-run, so without the clear a + * fresh reader finds the previous answer and asks nothing at all. + */ +enableAutoUnmount(afterEach) + +async function clearTrailData() { + await mountSuspended(defineComponent({ + setup() { + clearNuxtData([AUDIT_TRAIL_KEY, AUDIT_ENTITIES_KEY, AUDIT_ENTITY_KEY]) + + return () => h('div') + } + })) +} + +beforeEach(async () => { + reads = [] + entityReads = 0 + gate = null + + await clearTrailData() +}) + +describe('useAuditTrail', () => { + it('refuses to read without a caller, and reads as soon as there is one', async () => { + const { trail, acting } = await mountTrail('') + + // DotNetBoost.Auditing's own default, which this sample keeps: the one endpoint set in the + // application that an anonymous caller cannot read. Not a fault to report as one. + await vi.waitFor(() => expect(trail.unauthorized.value).toBe(true)) + expect(trail.loadError.value).toBe('') + expect(trail.entries.value).toEqual([]) + + // Naming somebody in the sidebar is the whole of the fix, and the trail re-reads itself. + acting.value = 'carol@example.com' + + await vi.waitFor(() => expect(trail.entries.value).toHaveLength(3)) + expect(trail.unauthorized.value).toBe(false) + + // The header travelled. It is attached by `useApi` and forwarded untouched by the Nitro + // proxy; nothing in between invents a name or drops one. + expect(reads).toHaveLength(1) + }) + + it('reads the declared entities once for every reader, and takes the page bound from them', async () => { + const { trail } = await mountTrail('carol@example.com') + + await vi.waitFor(() => expect(trail.entries.value).toHaveLength(3)) + + // The filter bar's entity list and the page-size control are two readers of one endpoint. One + // `useAsyncData` key, so the second finds the entry resolved and reads it. + const alsoReading = await mountEntities() + + expect(entityReads).toBe(1) + expect(trail.entities.value.map(entity => entity.name)).toEqual(['Category', 'Product']) + expect(alsoReading.entities.value).toHaveLength(2) + + // Read rather than hard-coded: a client that assumes a cap has a number to keep in step with + // the server's by hand, and one that asks for more is clamped rather than refused. + expect(trail.maxPageSize.value).toBe(50) + }) + + it('walks by cursor, and starts the walk again whenever the question changes', async () => { + const { trail } = await mountTrail('carol@example.com') + + await vi.waitFor(() => expect(trail.entries.value).toHaveLength(3)) + + // One entry a page, so the walk has somewhere to go. + await trail.setPageSize(1) + + expect(trail.entries.value.map(entry => entry.id)).toEqual(['e3']) + expect(trail.page.value).toBe(1) + expect(trail.hasNewer.value).toBe(false) + expect(trail.hasOlder.value).toBe(true) + + await trail.older() + + // The next page is asked for from where the last one ended, by the token the API handed back. + expect(reads.at(-1)).toMatchObject({ after: 'e3' }) + expect(trail.entries.value.map(entry => entry.id)).toEqual(['e2']) + expect(trail.page.value).toBe(2) + + // Now the question changes. The cursor from the old walk must not go with it: `e3` is a + // position in the unfiltered trail, and bob has no entry there at all — sent together with + // his name the API answers empty, which reads as "bob changed nothing". + await trail.setFilters({ actor: 'bob@example.com' }) + + expect(reads.at(-1)!.after).toBeUndefined() + expect(reads.at(-1)).toMatchObject({ actor: 'bob@example.com' }) + expect(trail.page.value).toBe(1) + expect(trail.entries.value.map(entry => entry.id)).toEqual(['e3']) + + // Bob has exactly one entry and a page holds exactly one, so this is a full page that happens + // to be the last — and `nextCursor` says where the page ended, never that another exists. A + // cursor walk finds out by asking: the answer is an empty page with a step back from it, which + // is the honest end of a walk that deliberately never counted the trail. + expect(trail.hasOlder.value).toBe(true) + + await trail.older() + + expect(trail.entries.value).toEqual([]) + expect(trail.page.value).toBe(2) + expect(trail.hasNewer.value).toBe(true) + expect(trail.hasOlder.value).toBe(false) + + await trail.newer() + + expect(trail.entries.value.map(entry => entry.id)).toEqual(['e3']) + expect(reads.at(-1)!.after).toBeUndefined() + + // `skip` is never sent, by anything, ever: the API refuses it together with `after`, and an + // offset re-reads and discards the whole trail ahead of the page it serves. + expect(reads.every(read => read.skip === undefined)).toBe(true) + }) + + it('does not answer a new question with the answer already in flight', async () => { + let open!: () => void + gate = new Promise((resolve) => { + open = resolve + }) + + // The first page is still out: nothing has resolved, and the entry is registered with + // `dedupe: 'defer'`. + const { trail } = await mountTrail('carol@example.com') + + await vi.waitFor(() => expect(reads).toHaveLength(1)) + + const filtered = trail.setFilters({ actor: 'bob@example.com' }) + + // Under `defer` this stays at one request and the page in flight — the unfiltered trail — is + // handed back as the answer to a question about bob. `dedupe: 'cancel'` is why there are two. + await vi.waitFor(() => expect(reads).toHaveLength(2)) + + open() + await filtered + + expect(trail.entries.value.map(entry => entry.actor)).toEqual(['bob@example.com']) + }) +}) From 5b7cf74453621d898fd369d2bba483cd659882a4 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Sat, 24 Oct 2026 19:52:50 +0300 Subject: [PATCH 04/44] Photograph the Audit page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taken the way docs/screenshots/README.md says: a production build rather than `nuxt dev`, a freshly seeded database, 1400px wide captured at 2× and resized to 1800px. That file grew a section, because this one needs something the other four do not. A seeded database has no trail at all — DatabaseInitialiser writes the catalogue without going through the slices that record — so an empty page is what a faithful reading of the recipe produces. The six writes the image shows are now written down beside it: bob adds a product, somebody with no header corrects its SKU, carol deletes the lamp, alice renames a category's slug and reprices the desk, and dana rewrites a product in Arabic only — which records nothing at all, and is the one to keep if these are ever rewritten, because it is exactly what the rules card beside the entries explains. The image is taller than the others. The width is the fixed part of the recipe; the rules card and an opened entry do not fit in 760px, and settings.png was already twice the height of catalog.png. 139 tests still pass over the README it is embedded in. Co-Authored-By: Claude Opus 5 --- README.md | 3 ++ docs/screenshots/README.md | 79 +++++++++++++++++++++++++++++++++++++ docs/screenshots/audit.png | Bin 0 -> 362783 bytes 3 files changed, 82 insertions(+) create mode 100644 docs/screenshots/audit.png diff --git a/README.md b/README.md index ab9a47f..3693cf5 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,9 @@ on the write's own response, in the language the list is being read in. A catego products is refused in the same language. [What a catalogue write answers with](#what-a-catalogue-write-answers-with) has the shapes. +![The audit trail: one entity's entries, and what the model says is recorded for +it](docs/screenshots/audit.png) + **Audit** is DotNetBoost.Auditing's trail, and the one page here that has to be signed in to. `MapAuditEndpoints()` requires an authenticated caller *by default* — a trail says who did what and when — and this sample keeps that default rather than switching it off, so with nobody set the page diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index f0550cb..4010ec1 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -16,3 +16,82 @@ The images the root `README.md` embeds. How they were taken, so regenerating the `catalog.png` and `catalog-rtl.png` are the same page and the same data, read in `en` and in `ar`. That pair is the point of the sample, so it is worth keeping them in step if either is replaced. + +## `audit.png` also needs a trail + +A freshly seeded database has none. `DatabaseInitialiser` writes the catalogue without going +through the six slices that record, so the Audit page is honestly empty until something changes — +and an empty trail is a photograph of nothing. + +These six writes are what it shows. They are deliberate rather than whatever was being tested that +day, for the same reason the database is a seeded one, and they are run against it with the API up: + +```bash +API=${API:-http://localhost:5180} + +# Re-posts a product with its own translations and whatever this call changes. A PUT is a full +# replacement, and every write below is meant to move columns only. +repost() { + actor=$1; id=$2; patch=$3 + body=$(curl -s "$API/api/products/$id" | python3 -c ' +import json, sys +product = json.load(sys.stdin) +product.update(json.loads(sys.argv[1])) +print(json.dumps({k: product[k] for k in ("sku", "price", "categoryId", "translations")}))' "$patch") + + curl -s -o /dev/null -X PUT "$API/api/products/$id" -H 'Content-Type: application/json' \ + ${actor:+-H "X-Sample-Actor: $actor"} -d "$body" +} + +# 1. bob adds a product. +curl -s -o /dev/null -X POST "$API/api/products" -H 'Content-Type: application/json' \ + -H 'X-Sample-Actor: bob@example.com' -d '{ + "sku": "SHELF-001", "price": 129.00, "categoryId": 1, + "translations": { + "en": { "Name": "Ash bookshelf", "Description": "Five shelves, open back." }, + "ar": { "Name": "رف كتب من الدردار", "Description": "خمسة رفوف، بظهر مفتوح." }, + "fr": { "Name": "Bibliothèque en frêne", "Description": "Cinq étagères, dos ouvert." } + }}' + +# 2. Somebody with no name corrects its SKU — no header, so no actor is recorded. +repost "" 4 '{"sku": "SHELF-002"}' + +# 3. carol withdraws the lamp. Its history outlives the row. +curl -s -o /dev/null -X DELETE "$API/api/products/3" -H 'X-Sample-Actor: carol@example.com' + +# 4. alice renames a category's slug. +curl -s -o /dev/null -X PUT "$API/api/categories/2" -H 'Content-Type: application/json' \ + -H 'X-Sample-Actor: alice@example.com' -d "$(curl -s "$API/api/categories/2" | python3 -c ' +import json, sys +category = json.load(sys.stdin) +category["slug"] = "lighting-and-lamps" +print(json.dumps({k: category[k] for k in ("slug", "translations")}))')" + +# 5. dana rewrites the chair IN ARABIC ONLY — and the trail records nothing at all, because +# Name and Description carry [AuditIgnore]. +dana=$(curl -s "$API/api/products/2" | python3 -c ' +import json, sys +product = json.load(sys.stdin) +product["translations"]["ar"] = { "Name": "كرسي من الجوز", "Description": "جوز مصقول، بمسند ظهر منحنٍ." } +print(json.dumps({k: product[k] for k in ("sku", "price", "categoryId", "translations")}))') +curl -s -o /dev/null -X PUT "$API/api/products/2" -H 'Content-Type: application/json' \ + -H 'X-Sample-Actor: dana@example.com' -d "$dana" + +# 6. alice reprices the oak desk. Newest entry, and the one the screenshot opens. +repost alice@example.com 1 '{"price": 389.95}' +``` + +The fifth is the one worth keeping if these are ever rewritten: it is a write that adds **no entry**, +which is exactly what the rules card beside the entries explains. + +The page is then photographed with the entity filter on `Product`, the newest entry opened, and the +dashboard acting as `alice@example.com` — that name lives in `localStorage` under +`dotnetboost-sample:acting-as`, where the sidebar control keeps it, so it can be set before the +first paint rather than clicked in. + +It is **taller than the others**, because the rules card and an opened entry do not fit in their +height. 1400px wide at 2× is the part that is fixed; the height is whatever the page needs with +nothing left to scroll to. + +It needs no placeholder imagery either: no media is drawn on that page, and attaching a file would +not show up in the trail anyway — `MediaFile` is not one of the two audited entities here. diff --git a/docs/screenshots/audit.png b/docs/screenshots/audit.png new file mode 100644 index 0000000000000000000000000000000000000000..6b73d7720745ab62fd62b7cff6d66a438a716371 GIT binary patch literal 362783 zcmeFZWl$aMwk`?;f+TpbV8Pwp-QC^Y-7UDgySuwXg1fuByE~^>ox5+H{jGKOulw^> zO^P|83ubq}?--Ab87eC+0t@pK1_T5IR!me-9s~pu3IqhA5(*6XiCcGkA@B{#xyck~oOQyRhFfe=}p)eTc zGZauzl(|XmsqQ>{@({l~zu&(}MZa}EZU~44-f4H4xaexdA%GF${b}b}AKPf%Uo$aw zPIZ~!8YJfA>W;qZOESh;`xi~r-F28!|anCi_aZT=6(^vC@UnAUewHB9)wzMl|YJ){^~^%TeX z=KtZCTEbs?4XQY(7)1Ye*8lHYl1%|8j6fyUx~KXdj-iAWMW;#%77`BqA5Q=OzeN8Z zU80Bn8S?+@Ewk-K7#bUsl#<%zqM$epqEin-WdLhxYJ!79#Z^VwQi1T6x2JzI(m8r ziM?GsaOShsW}DF@Dre`SVRm-*Byy#Z_4W11>b&Y|8X^_D>6zl(BefNIq@O>VJfH3R zx{|0g6iem%>%#Gt+MP}pIz1j+TUyraM4Bns8-gNCk0b4NcXvPEUChMA!}Puo%uvPO z+)*mDJ6lQrlGyh%&QkFS%1l-=E_HsnOFdt0#^v@Tq^3^#0$M&0iTV1H)NQ%i5wytt zAI=lO7oQ|1DarXjQdM5QEu@N&>GcS^359xT_b`$9dT5gU4o}u35DXVmpQPo6tIMv_=7_OL zuhq6T(V}?pzSinsFc87z@qlGqTwJUm+89S9kwl}zO-gFIR5PgFWa%x$FtQrtd9mK+ zv>J*`veICt)nuvWFV+;qyLCTG!_fWy@T%4BOhiJGzfI4ChIZ(Fe*%yBaK!bvmMKHI z?7krKA1`Gd$?x!^;dm2S^|yBlc8=t|s_rAJsSp-qP!SPG6O%)FBhH?lJn5|O_qVrB zPEKTV(5PXwVdQBU32f&+?T4S9glu?RT)Xru`-u}%8ym(SlOnIMmF!ocD=oeUG_FEL z{{9B-&gTf}*DaiO_~5wm9jq!9s@Dq|HtWtBhi??2nun*0)joT%BDfxB(?wG0s+iGL zB81>CZwKkaG@W<&OqB`T%%2);*6Szot3h91Zbk{NY0sBxzswX*reBIVkk)qdaJYRK_m z{MF%XX)!NX-D*|YwKa!N zCseDs&2F$VQ||PVG^jl(kVNQU{xu;1{)vDm6xxk z+eE=bL5WI9!NN!LcD?Pz%RHWQu)5!&C-kW|nybCC{ruJoTR|F~q$T`ZrR9Nyj2uf) z${T7_J#34)IyTlY9N%pKrIPCuGLH)#GuGHsfYP+PQWTsGpgN;os z8}cqd$z{Xsa5tJqn8G+YDXC5OZx5Y;c0a~#w$BG2dLz!L`;Usk%6si5DQAs-s>T$( z(xghiz)(538LQQ%TkG$A9!)qUNeKye^C~(bLP87Go4PXvG-`Y@c<)nJ*Mr$>-JgFB zvt=yj%TPud|1zwGsOqm58wvkVZ=!Pg?jCggY?erYJi6BSADe$nB(J^wNUHl0P`^*bQeak$IUUq=qwYcek0>$(1Y4vQqWb zC$kQ}*+|W@J9641VsLSCf`O6OuVDL(eSw6N&SXCakP%%PCOPKm4zyqB%F(e}llQy( z9OGT%?qGC%6b`%yduRyj=!+c($Ldce^Q{Rp#zESJI1yoCucvL1Msq3Ri((<>apNq! zHz8r+i;d1?({w05$`g~yPPxys<@#dj%$LVapW)+yu;=?z76i~glarH!gM($+lmkrP zcP&a(syTc#<4MjfHRT z@=~YS8aqwnWD455doUW8!;L9O&FIB%55~X3{%o<`)?po(Nk`=ILsYYvr29koL#aUT*Xxt3 zhU7}UiH*KK*lek0>&$RGNiFDen_h2FP&fJX*N~&1$jAzn>P(FK13l;iJ1JlbpaJt65xVYXvHYzO}u5ps#OJ z7wB*YNlJQ$_zGv(mi3l(SH@#wb;n*ljZs|hv18P|eS6O-u^A~i4$S*|u`MV*nJe9= zmX3-r8}X=ScO8P}3Oy4G#Ak{-f3c^%6S=5o&->e_hg|Uj?$1{b7j=9z>I}&wc4N;& zrp2CH9j3FrUyl7CeB}H{hhMDkMh>%mnj8)%&fZVT#k${azJ*cAACDwbEL3SJokmsy%eLU6R`n=Jxx)Qy2JRe{l*`Y<-ya?hY`n;IExNLg)+#S&=KgRQx z$`^`LPU13}f*@_LG@c4aVI{`I1YC(5H$&o}!vtLK>fmy^#wghomE=qIk#4KOva_$d zd%Vhr5H&=Ggb+57)e+3{tr3>1the0@N4S;>g`)-yiH>J-xO6>kK(01f2`~!&rBsr1c01;Z4p|TpBB=9fPD=B7wyPMaK9M_$X;a ze~_rFoN)@V<-};dXN+si-NllneWXx4Lm1CnpUqbVe{OTp`Ao7m8BSa`G)Q7{#IvUv zePpv>t|EzDAJ@{AqP=v~b?f$@);palx2Pi7{}_f3g{A4g-W{AwxvsCTmpGiryz=}G zr`-rNZ32-f@6Y$<@id5QT=sRK;$omAs^$wfKAdypAnp7%!@#72LlgDH)N$2&lHnOQ zU97TNtkRbz+uQ47w@rW8 zKG2edsXGp{x;z(uiKx5t3P(gl{3(vJ#TjxlnaFrPZ{BpiSd-!|0m>KyN06wbWKr*O zy~(UVG!7Sn8vyD6N+%(}&rd9qHJ8KZ~fKRxPSAn-yO3I2=n2%v9{#L|$B9uK(OR<{M9AO5vU;6I8?I_5|;B_wZn{ z!D;Y*7d8$)wFXd?Mx%Ks-SqVI_BJ9Wi?x0i?9aK=>3k0E?$KnLY&rvW>jInZ_oRMZ ztN+M3_xOG>F!XM0;6=l%5P>a<{+XD-A>nb%TJwafuD!e+|0aaq2$l2XmexSWO<2fogx(5oT;|kyTY`l~kVY^=`bc8l=;ndq}f+#^Z!Pu>dC{QO-T)rE~WT!{h3a zTnu3yEhZ*rX}Qs{|F~&FK+gU7<`B*G05z=*kHs2d4HWCD>h3JTvciqdOME^~d9pUV zZ_PR$R+N=>TsVB|0u-7JN0+S*hd|dMfyHFzCuwZ@b`B3eNYnks&dmH3ZF*7L10@tS zaIUyxvKX2BeLEP}Q__G9KSPv~>aWwR3zF_v;~5dN+oO$TUx9PDJ^+FtigBQn|Ck&>YV zBohUnAMJA+8`rTSIQoU0>-xX%6PAS^`{Ef1_kJkjiPYI|_U8Xkx9Sf=p?j@rci+Ag zV|syyX7hcdkUx06>>f<>5?6)$oDztp$4Vn6kdHi`&F$>OZZqi(y0=pkG8%{g-a7Yn zA1xg2U{rLzNHoN^i{>C&ah0X7Zy)cM19)ig%0Hui-j7tMIoOtAGoR6+9$-c_EJrN< zM~Dq-0e0wwb(;5PMc{;JPD4aAzu3&VA^mJopx%t{+cPUb#Rx18rqZ0xPDz|BVx6f^ z?Ss8DR?nYdrP~DNVFd1cyoE)jv0{h^(dF%4j~`0pUt4_cCmz1E=d0qtB~vQg$tgr1 zG=H{r%}@!L2+=UOt9Q6Ui(|kA`G1A|6O8Cot=-w?^|BV|kEgCJIwTgrfYo0b?G$1e zk~l}FW`5t39ebmqjUhzwhSmMFGupiIGkaX#ox^@Fq`v<2{Sn?6k;|UYJ9hoD#o$GT z=W@9oGFsX%eg+y*IkF4mN8zNpRY69D0;8c}hVbq#@-h@Gm%u^cNaFi(q!xv81;D|K z_Mf;O?*z08***&1UpH?jI4`!O50tks7FZAlaa0bsu3WMFwVINPSIsk%9#-3uyb|b?fEgT&sfu0ID zUl}EZGOP{-D~%TZR+0rCj~qt9iRz>5DzDR{h8x-O>tB}_?zvmVjCw!c+?SS;!D230 zZFEfE(+li|fq=sR^#ngWz$_$h0jx;MUENr{OzojTkqI)&(5)jszA`gYtf(L}cPIyB zWyOTa{P=#%0+ytsX@4mfokI~#!DqosoxQCNc8~UFP7&T=?O%SIul;}PJ)dt#9WC?k z=Bst!nUV?whN7TMf|L&Q#x82m%JUL1A%NTSlg<9AezJ=ZK#BnLO`tqBwYL9=>=SP( zMPhf@8&aY6R7$;RikBs2V+-v(ie9*fHK3**MrXg{%QH!HF^aPe*(#nblKSlaeC5fD z<$k|Y0RJN7Q7ERXYm`*0Q6w;==t!X`*F&ff!X9d355R*IGJd>zf@pm4&qDr# zF}ym(4r_Ywn4yB(PpsGJa0n;Eb&qbxYi$>l*7Z;2?+2(aAe>C5H|yRWREA`Qz(fpj zagS1zE0)gW@M&GFm8-B+PJD-EV{<;kG#Yj9dVgTut!jm`Y@b6|qXoES?g{wU7oD=G z5uqGYs2^3dVFmgNaH*pZ+2RVQMCtdV{Ah3XbBw)~Ct&5ZQmgLCx6d@r zCeZB_FXNp!9<3k2`7E*Z81>+>j^MdQK6;L4eY}rybH_#zz|YS!E4ZF${i6?t3W~_+ zu=!_5Ko)ZB4T_{pA{m|mKtz`74q1cTLO0Lt*zCpz zIDU=We&V!gDcc14=$0|O!7WKRY!d9(_i7ySY$evOLk>T5_37X3A?M2C(>)ztA2JQU z);Bg1B!Etg{37^*9-Otg2$T+1_BcU`LpC#ihwE4Z5dhHPB5e6wcD`3-$_WaB@7}2P zEG{m(I@+Ck&Gfc8oqm42;`Z$Bphu0;lCY6ztE-difU!|`f?>#J{u5vD{zcXeB*LNc z`Dmr3q|8WnCxqyewYI!|C6mLC4=lrEwp6dto&EywvqURznbS=El@jY!)|zs zEJmeR9#a5)?l%+>Yn%Gi6{1bLd||@DhMyp2pmdIHpxl^9Ed6QtVItXwEocUum}>hO zqdCgz0a;cwg*mE*B^2SwmMNxezGJnol6azB2wS=^P2IvG{rLA(U{K+qvSnBp;+#U+Y_ zg5ngFar60BpK?`HP-4E>rIpRyS&6A_p59uaiSY(7>-v*WGFII*FR7{_!6mBe}Ks8a(iIq^}5mjxACkk3|8?KO~=!0y$u#C9~zmv>vkebWm>FC zDrA*Iucz487j(VdMF5@4<>D*`4<4Jf=5yU;)9vanWCZcp{@$>HX2j5At8*UzKY36~ z6gYwa&-0#-#e(f%!hDg`sPTAOjq<#wN_uKiP`ttlz-D*LoZg;7C%Mx@c*rbXTSRsr zPTH0_JRZBORv)(R?L((0B?Q*&@{`uQJv>ga7L!f$^btwX!4?ZN_jI5KH$wreZWJL( zJUqL zmg^vJp&6eSG(JTTWa0$3)lW}PfhKqg=)p8vFP9hV?XY@tynlTe8fd@LJIeG*?_xtZ zAB=4p=;!6<;ct3eYAHWr?)Q=YW(EcC==362x*3do3?T#KRo2zj4TLG0Q|%aP{eg>X z5}}`K!*c!gp zWW~|OH(#c7V?7!+_Af5*xW*vd+d*MrgNWR6R7aLk8%slF@&-XROBs+?uj<9k!&*!k z7brqDNZu&1Xm++-QWz}*aTq;d9Tp{?i^*Q5I$UXyEgsLa<|ed2kE%YoH@s<&y2CQj z8h)mq)H?#O;qtw!ZTY<&zC-=>=ko^c{}jeJS@B3AQ|j%=XuNt$O$IyJLoyA=q@X3blIU#|y3^$>)zznr=RJVG^RF z!vr#g(6Z385VUl;Spy_0J#pY?q=Eav;ZUoF;E_#4?jS4VL(LfX>V<%8`=8n@8_>VaNGIu^3;BD z5@-*);x@#P6_b}B@8umVr!EvDe< zuYuE)i1Di~l zN_7xdM9*5WRQkcrPFRq1h*E%E)N25A_H|9UdL#KkTzOMr3iIA#n3;B?-NAv%`irN) zooPpUDNP#h$Oa*!C5Msi&9<{CBZ3 zhZW%$4RzU3PvB=xSl9JHd5Qhd4>nibiMQk8?8yv1x{jCjj*NF5IWb;d zA|Ic%2uT|JS2c-jNI0zJ8a;}~*G%@tn;Y`r*dX|LIkwNsA-GNNS8Th_x@lfo3@-P( z+ex1I&njxAc*+IJY(S0C3+f70rL*1A6-)$##8-K#2z%wJoxy<{HRyad|2Aq5hgMgZ z8Myj8H|}?&cWp=5v~Vjo**|dc-+Ofk<=zDRS6=SCN`!Tt;8k2iusU~lXXt0@Jj>fx zY6{pXzKP6!9q*J#viFIx83ug=l_gf)gF;wYv54-a796K_@% z8LdBV@TU&TtE2cjjpSQk^RNjjKW;`P9hFiL5(27Kx|B^NjM(`E3_U*Ho^gFRx8r5F zy&jf}2O~Wq&2IE*XqdMiUQVkK@Ad#GXLqvObegB68S7_Py=_~^W!DEMr`|}%*Y8K& z?)PPYOv`gW$KXlD?{zeV=b1>O-NDMlG=7BO7*Zx$J0P-w$byWtgWgX&Ov=ImHlU`` z``j!X=?<7!pIiR$&prESfse|S>YX|%sLfRjSLSu6~VEgwu` zpM;eER^-+#4E^#m-9L8JH$GC}c1}TNBJCa$XWZw#@n1${1++r&tuL-30XbMZtGV&u zBqKHIsR>HPKyGesM>4rmn<81XB@9QgBOov?S-AGt7ZDK=<&R>L$Qu$ut^o3Wq{67X zqt!nn8GoGccwd5B9nkD`7;LvdQ2mYD|JJ5B9!&(>y;`P zVy>-WSZEUvOjc<$7fNUTR4P-{cidSyU-$UETARjHUdMee?=W1Pxwhpn`#4O3`xCk( zQrUHL+bC%ghgxG?*HqB}iguJ^8 zC(fn=Zw_>nnACWMa05vlEl= z0a_<7i7VnvbSuv+rV4B88Bj^T*u&s0pm$|B3QgjA-Ep%YEL3XP>QiYnVU@3hu9)2k z3$XL$WH6c`l_7ld2o+l?Q~Cze+9O9kjBFH}EqgZ=L<(rDIMPL;LP4d9#`i2``*B30 zaJbw<1{gYPcv^SS5rfA1eo$&2b=&ARo6R%P&r};imQrefDp=^IS;&|h<~ZKkIOMOcdd$j$C7uYL+YB^9YGnRHuWy0M|yK@6?k zS7y$9Wx(VyX-J0z*L1Z}zqy?vKEnIFyLiVy;r)x$X_uRwbh2Y@_4<&*YtYrVOiT}Z zpTizrdV|`x!1bcplutzDSf{#B7h&u<5gR?Hcj@)GKH5~6rUy-QWKj^`wi{(_Ia_Sh z<=JuGBppx@R8Uw0cyy%ocV}C+I1=HgRDOPbM^uNk%t0+E8*HAlMem$OBaCa_knmmA zy2TNXbhR{T%vNLmkfyt=9RN}ChlFRgeA=vUd4GMZ)@o16#*a#)V=o2|v{UELuC@U9_T44DY-qGiE|WFOz;G! zAuTOI@o%;W?=*5qc=%v!IIr9L%X=NIusxV2D2C&VC_7@KWEK-fIOLqbyM_aQwCe$%>KWLE@2OlP`0 z>{ZRmvk?;;562O$HeW`BKs`QI&vm|HA2Cy!>^aw|RG&fn9_yXVnYB2_=Jp-w0f3$| zhRgX3ou!ObaofQd>gSoz+ApV(>h7wQ4!3XNLDzo-%!lTs!|7Q1z9N7B7EZ{F-KKnd z5a}>4EFjRUQn`YT?Nw#DJky**YaaKjl5zSr{f%#UXoQd^UTP9lX^2~=%2Uh(l0$_B zQj3Yvny4P}cxDhTe zCHRM^SCfOZFaOv5{_74!__OgK(#7Z?E=6G_#-P#V+VZa(Vw9j5mytFf^8><+ zal)S39kcYLBy&aPhdL`Rol)L}K;cQZWK~RuS)2SY>C~Iu1~Ud$9USi`zK()#wj->v z)#{BuiQ0gW0UH*R#e(Tf!7yNAk56_t(vEEJwit5sp~0A$ym+TDow_K>Wf4W-ezJGw z+9T0}sVJP<5Eb#VI2vVGvZ^(kPJ51})b>Sf1z$C9dh4NeYe2B_IsIGGQAI@ea^pR~ zoxsVX-uQE0bf}R6KO%OMb(MNom`p=Y&U^2nOfH|jWih5W%LN|3`QcontxcQ$@1@ex z077dM{_?Pjj#s1Q^2pd2qxJRG@8{=39aL&fz)#9XB#0#@k;%g1_S^$hx|h4wa=ux3F@dcL7AXZlsn`2GOLYf*&r*8*A!Z3UHyisYbc-XTPm^;5pq$f&(I|l#sE>f%ca&ZuUH!T0x1WypS|)C>ubi#pXa? zuTyYCLkr$4#^S8w`O4B?GDT?NNllGLfw}MsW4bCU22)Z$>Zky2ux}FLSLTqqti9y5 zyiKjK%`fx(B4Gn4_IwXzTJ6l65)EWaAGbz*(OFly}d_ed&>V?Ru==qY=89Cw%K0oONEhaMnDp!ns_|N<(c<# zP@p; zJ%A+psYT6(dzG-S|+E=Djl2_~No2b!j zEu7=8NvFNp!;dN~b%Jc-LA^qHC_Vbn)qH;q&y{$jgg3gr`!=FX{aZQH3$yvg*|Bc5 zkUW>#YLN1ml^>w-hi3p%4XiV^nN(R5!uuvj@(CV6f~3)8 zx$B08XK~rG`{1V}^sI(AifSihSHt5Ch<3<$Z%N5ir^go;&Rc)*?D2E$4`$W9eM7{f z+<+EPi#{AJ+v$D}#0#@rNRl9FC?U64C@)7=DE{eC2?_irb(WSU`T|C-5uWvo!A`ih z2ZUQ9;mPDcp_!7hi38<=EhPXMjQ}DZa0_T?CNnu6Am>c0n*90SQm=?vCk zF0IoAzUOQ*CIbWHk0*mFAtq@tF#ml!R)MyNu!No7xs%9^jfBiJ@**bJ;mK88l^OndL(vJcA(Qb7DT%(EcITGIosA?M(Vanu9 zWuyzJb+qo$LwN#U zpKeBN0Kpe$oBmSyc~hb*zdt+~VIc4ig+C;Z?)SlKe0mn1uy3f8W5u*1msrb5K$!Ol z)WN}_nHj_)R;NN-dsb}#uS|t zJ-D>$9^Y#7?%9Y?G1Mu#J!6_`tTs1w?DlX5d{G$jrtIv`*S>&;OP-ZZpGh?Za6Z3K zc0;#k?H~5|cz6OM{gK}LKRVxM1ipp2-5&4(9=|6LAWEkCHZdfM%jF*6w9svVu@5jR zBQ;Q&`H_TYn#f-|b)42a!d%wT_$Q2#}0Swpqfys@EI;x)qm{WcPexmDmz<%t}kEvsmQzi#hm& zi}DarlrtQcSkAabovhQY@p^6Q4el0|he<-85fBtKOlLj^a$jUT`3fdOF?f~gjUT7g zLcqq=1cGcCI|8c;&*VmRQOl;uFWL~|1&7H1;Zdp7Xhvr`DSX^` zz1s9q`HsVx!y6#C;|nALQatY}tW#*Wkw-6)*^dFTrQ>;B==J(gJ6$+>?wN{ifgx&X zl8UWws;HQ-e~cTwORiMr_;fW5_#?CRM(4eNrfX(xmUtSpKR8ndPOxbHZy9>uJi^~C z!#fuahx(K(Or`{^y3AH9gwg3}hAj}^KJ;O`j51y2RxB|_2M5E)B!G>>q9`{~+bWmu zPN^=!!7#TmTiiQL9ay&1Vz=&e+7uL!u?+-`pS22xDmV5o9w-kCH@7D2rt^God_2)7 zvaGGEfZT$e%VSN8o4UEikcRk@Zlr;{Jigh*H4>5E=5)S@j+@aKnvUezT5EiEcDB=a zx+^9co^SDy-Wz==^5(8J(4;; zQ&+}Up5GqU`E+;=w}gtL&!S;VE{x+uB$hH@WkRE+1=?tf4M)yq+T(P7zq&!vX`~0V z%xP&%^t_bGJf=Sh&dmt|*78;Uzf2;)fm*83tMB%C&CF`gZnj(=W8v-gem9>h zv0jJ9Q$Lw2Z3jYZYz_xN+Sz-dM9x2C!sFo#NP`IjDYfS&RUm-`L@0jhWj(k3>FM%% z{R#;gzx$DH(*;mr7VMb}AK{u7?n_9=LJ5znOzF2XKv;cwawT|;iydCU`8VMRAQ?+_ zMyDK?o_%$$7oh-1QTqd|N#TiXpd7@Bz`)A4WU{%4UK4R(%*?>r*%8Is(aBJm(ts1h`<8EPgiKvKeXPKBJfv2?7`pV)WD0Av1Su(XaW}b!t%2m==r6iMiRP$a0}!K@odbVT+CuZoh6!glU{e3X?mj~V0S@kb zw&Z&Wd%6JyGu!GQqBv0~k$g>|R4ASZ=q)j2-n#>^1_K@JW-~0u#~7?hNZ$?s5wrdc z2FV~D{0>kjJWeZ{i=R30pLy^Pi?z@eChca*aU3)|I_-hRs%CEAyO>y_s4#u0l@P$nwYdSp!7=PeA|N- zA6S?2^ppptM}}7%v8U~lO-x*-BeV1(_Ah7%_Z(GKS(o`z)_uQ-U}I&d%KDtCLd6Pq zc8q8**jrzSF5jKiPXf_)b@F)GW~Wo;NzU6J9<7(V6Vr90Ef@{pTKBi*szBCIK!@Aw zeScUrRqAwym51)U9!Li!JHFG>FtRv_t^y+W&bU-*k7jxmyR%qtg|t)5)c|BAW>H$yTA8 zU8^qC^khVInSLv;>P zEUjtAhOKQJa9*X_y7x0dffp5~*VH zR(pnhecFWw_H(1Y5G{``4$cK|-5jg6xq$hip5ERFA9h_ISy==#P#_;^!$wV7f7$)H z0jPEZ{3zZEx2|{QW_hsx^qom9!HTZ@nTfmU=v(VgI$SO}068HT%!v#!j5k%G=nYVO z5f$fxaoqp3$dOwTR9AiW_3gdCMjIOD5EPy-J4{tnAZ8(opmO83R`kV&OH(Vx0-CtpAS)xG8*f`rGxVzXD=>~iV6>iI^Q zEh8ZzUNxr}XVBD~_y&Puu zr?c1$goj|=^#*YXvt54$_UPwRH&=ObhVzA zYkbsxpZd8V3Mv=4dwjC8yYSqut52XU_`Of1Tj~SX3?}GkFWaejC`-TASe=$=KZS3$ zs1m`$D;#oAj#NGMF1EW#0xAl0okG>n?{lbvrr|&1Y@MmiJk{zAvnI@}9W9o|TnM<* zyPCukwjSebnDaF+|2a%!+X?k{)q7{t_1q;-_ z{eA`4I|XnO^)$IuEp#f?bL=1uwa(R>#A3y~#OJ3Zh5HEDh!iSVWmoYsPu zXsTl7O1ZYa9>^CXQ(RLeI;?NOMX4RG8Lb)EI}BE4cXobZ2^7Y5fPRd2ULyg-xl3Qj zAn%W76M-O3TRj_S@C8&Vb)e1NoK}mnoF8nc_-VUeR-no-8BKw;Mr4`znNVGp3;d=Y3ynqBs4YFwpQBge?%{+(~}rp zXBa$ksn_e>+Us=JJM@+@9G7AYfH&JfNytn(lAGylEH+xl+dpz@<{SIZ3?T-QhpkD< z7r8OOiDZa($Vd9cPrCk%bwEQbi1}@Ny3k@@uxi|!7u}!nJ2)6J85$l1NCgkkAS7=L z#~0V6GCU8(=F`#DqInlq9RAJk7z2W5x%@lDZcg<0-7pJW#;XUA{yJ!<{M6=ilG<-NwQ{Fx>+ACJalpt@i{Y^R!uqZQFbWt6RGhj$O4ELUzmpHIckzaY zOJOmUt(IaG?+0;<#-VeXQlA-GfTS< z82D&*ub1z5F7NLY?(Rtkd(@V03E_sraVS8X&t8duK`Z%71)tCsITX-8+%MK303DXY zb}OcnOOMae^-9<lXcR-`d1Wj5^rZKbb?jUcWFTzl z>z{v4V!({U@k4MLIBI(zwy-0eBjmmmiHP|Gbuh z|GFzSo9wKviLq6vQu!*?7gQQwfyUx6nq(CWk1IqQ85#Nb_$Y!y1S;KZ{jZ<|_M~8Z zfP92bp8!D^Y4{u_W69}3k{2`((c$*?<^}?Z0LOOShR*-xw>p(8aMA5U+ryxbsGYY4 zCLrU@)Bm(a1~Lnyf<8VrWKXd__2~PP`T{PyyOoQ9;5L*F7GEGeVMrQRl%;qV#+YjRK z<=>!Qot>RX$jABr^;rN2=FfF0Tpplb^0v^!*IGfSGmeTt2EMA~$G`+BkA?&Vmu~Zo zGZP6*iAO}Ux#*cS@6pV`k>8CDL537KXE&J`B+T;xwPCWZlHg zPFF-HT0@itM*Y+(QXMka+~6P()A*g?PY2O4uCKpyMKa#n3?k8TF7$O@>9s86$b`mBn^^DeZTkh=+$j**@nfNrP3GLvI>oVhlQ08 z6{XbK2$ubElnYf58@pYAv?~q=%_gMajHu#=4QTc9HW(8ba2#dOKyTA!!Xs+?+$gMd3ymV&BYR%i&O}F&r`p?v? ztiNFHx1>c_kB1bgeY3ae3tv^6p5=Kr$&^N5qb)o)J36JNNlj-uU#&uNg;#;G5g^h^ z%Fh1PrUig`fN#HU2U&h50Wjwb1`{B>e;sjA(NG)Ew8EZDjCMjU6g_WYL?WQ>S7K=`VF;c1&-z9*| z{XM*`1NrdDO@+Tl9>WYhw5V!Z#ck2N9-hc9{fvZSwMLl(&F9lo1J^1+DpK2lPqLhH zXhOkVNyuEuRl!uulU6#u433Pb#PC?%aN8ESxw@du?;IaA7LiMsWHCVDA)MwB#C~AD20uuY^JkMgSpK9Uy<$ zFd+y4i$B1SFOX)`U^1IQ;88yL)d7d&3Gcm;f?A1^>9E3e~$DIyW@Yq^xfhAGd|A7 zm_Qm>>RNz;mTovWF7%6tI& z8{0D!>Dtar5W90ORzJ04Iee~Rb3xTGJD*&jpx7vhOy#B)@t$imX^a(joI#}X8w6A= zU~IyFuZT-Y326aRd%nPoJF*;*mc6vIx91<%tT)zDRZRd*Z zLR_R+rP)LaNhWvILwbNu>3kvwO>~zm1{sALCKV#pIdpiNMzW_pQtO;w=bv4MGPl8E zqx2&+PEagI(33ELwAFBE0u^B^J1(xl>6CgW43HJf7b7typ7_@ElhfM8j#r z3Y9IgqRqPbhgpq3V@g4r;qdXMx@gADzt;c#15*e zt9}Gh($eJm=!jzH>-%bBLqpMKt^!V@L|$kmwgWNA;AS#ZvyVt|pQ}+<)X0u``)Bz) zu;xkT+ZoH2m6c8n6_psE{FH+sVI9=iRZ%*P&L_;A%N8c!)RS0Nc0k9XjnQFN(d4Gy z3e(Q~WOV~bvd9g#0mldgy`E2055P|& zEsB8G9TN97YmvlLrNNNJ3=?eW7@34(1$3+afI+vVGxncfIb8o|1O}o?wDCfiL=*6j zZk@Q*)~t=5ImDlWUO-gPk&%*?r2rd{uJ_gOb4BeK!cr!?L$ldK8<>MM2^0$(@fJH7 zKOHr!t`-~sr_2cmPE~6UJ8dfU#Ciz08(7R{VRxdk&$(UfRcdIjLG?6-dPBC>JbAVn z9RPpWYJ-5bRnB#ohC(#4D%n3F{Z*T149>6E=`5!=r>I{9xdc>vRcn7DbkbuDM7g=| zj450#&f$iQLOj1MRxWo2irbx?_|E0+$phAg>Pb}wCiOS}8yfqc&uKX5d^kkT>4}NB z8twrBVsGDC-KaRL22+rZmykdtVIL$XCj;E8zApSU_v-_#O)`#{=a|ic6*K1RQ?kW{ zI-Avnoz-@9gwyspVzt-flH%e-QD{L+`1ttG1l&TQoXN^XhK4yLMIhhEpaB6_vP`uv zj<^jEIT;xs#~UVd)|xG~*xA}vB`0$i)T9R{a1Ij>pd-Rr^ouyNc3Nbqx4bJVc;&mT z4kZcoUgl?V&j=w@0?dN1^i0nVG(>>?~PxsAt*Xzs}Z+YNrGC^158U3+&B{8|7Sg zXIMKsC+N}MzHMNtt>)j|^nXp(G&eW5l9P~`P+zk6Ka=>samQt7QcM1y)Er`a(B7m` zLHM4k0yxzlYsYBl^taefr=yz-CJa9lWdQ|1$J7)y^0{m$)$wOe_IYjpk4dgSo|z@+o~i@7RU?E^=^Fej@F67} zSbyB<;14~&k!z`r;1}@za}4vpJ%K0>f15;Ik&xc~`HTO@B)}&D+bT$r0;E3<=|BHr zASdwA>Ux0f`=b}nf8BVH6u`EsEuuj1??dujl?FaqR=ffgGXIij{r8Hbwf(lOvdr53 zr>fb1KL9>}%LVvo&4-2WqyKG&AW$hSfwR|3ynK#7S|$DGME(DEil(t0!+y`u@+{o+ z^fW0&E-;$Wa8cUj|I5S@Qh+67kI(x6Rl@68R7C6I!U+t5_ACNv|M!mhpU<9&5nx{= zp5z%zv%P@e4PVoNA^~ebs`1bQYQUJoA??`1xYNs{qq6-pU>F; z-m{H@#9sey&nAMgwUZY;{<}Sk|9j6KhW8TwoBh~M4)$yrT%pImc}7PB?AdZoQYO*A zOzrReSOzzZef^A;mG#A9E(eC^sth3wP2n{z)yuz)5CQT9ZLmR{M*KG#$lBKsDKOJB zGqW=@Vg$_-NX?u7Lc{oduvmJbQ8H??(9zNH@Vw>bCShj&G&_Rm%-XwONdA@4*ur9K zdwY9#*VLpC7E=jP4)M>wI?W#zBbYHEJlvmpcUkLbb2FG#PnSeH&p$NPmx=(Y;`qC? zw6v7CIACXhoXyK5-4TbJ=5K3ZP4V&BDPn<!Ch%7KbtF=6Ja2WwL$} z2(ZWE;_5&>m5W;jbt-8TJC#T}uVpiZP4Lp2)2HW)w;aO?Q zGYVd9;eqcb%iOH28dL2Pneq~U`X9g>?+Q6Z9lT1vyS)6hG%sll{z>bLH}2PG(&GdK z=HByCVq%r$^$-X#0snJO|F@&w?+G?Rt0UL`4t2h>vjeQRJ|Hx_JNYt693J&73|?6g zgqsDqFW6VWEA+?d2!!)240{`hlMpY4bW2M?0aN^%p4{(@oY~7JcuGMs5dBncOHcbe zen`NV4;O@SeTpSve;x;j&-~gZUSNE2Z4X3O^FMGf{}@dmtj?7BTcfVxOKI;#6wXL4Q_iw{XbXQ{`(ui zC$_zV%bYUDyT#sVD!%Lc06h+`XF^=N$Z9AgF5Vx{yiz1wTU$#byV{>5%pM}-2YhK7c+mwrDI5oU{WFXC0lfnaa`hbTU`B<`6~E`1JIZlam0T3bnJRkCzHa1c^@V{64e|_$XZ?i)Y9<2}022A$~kOeO6ed-TuDb4GM4G{d8YLtlx3us8dm7huJ0V@GZ(uHIQfLM_*sxT;n`o0LE>tt=S2Y=U4psDD~&FBKi2MoidU#VJeB;1CTMt0eC-B z-2a^_1Sc?d8`xgu%B1o$KI~UM_jr0Z0{WWC3VoX|nOz6LRHiQPxmVZFm0>ONGL+cx z#&Vh6)5(%oS63yXIT(UZBid=ARp_8cP@))vDYG>+^hWn?TyiMN5JMvd`AyD}$o=<%_CG>sBqeR&M0xB&-{$L-M$;3njj zJ)5@@VeA={;xlP*quhxr-1!Wl&X~KzACVrZBRe;Dg)?a!O^dh)7v|UdoES9eNK@~S z@vq~9u(JKW^%Xk^gOioA^6j$dkip3DCE_4}~gP5801J@4S# zi2#1u6h-M1EnRDyVK32^BWg6T1VA zijR*F9!OdCXKadkN>wTwYHJ-HU3wdzXl8?2F7J+P>veepehm%f^i;KIyzP8@zUmm>9zfuO&{ORYEg)BeHuGUyAl{=n{X*4@SK|^Ew@MqgY!J7es$B;Kqtf%_6wk)5| zOiDv|9FLCy!Q$)XkkhxIlRQ}lD`9mih%6b>Yq2PDkJCOsz+mTR=e8j~A7WcNIi`H) zd_Hh^C|+Tk{Xs$V&+BE50I5Vvs(w-R5e}Q~Gaj0CfpqdLG8bcbSgag16~*y3^Dx5M z#l=FC)86LL+$o8pER2F}r++!f6OWc!m)85xTkj4FV>O&(>}Ft%5t37-OPuXvjr}wH z&J8XVn&dH~w}RS$%0A%eX2tIaNMYm5=IniaeT$V*)p8``L22LdW&<1G{@wX64qPwv z6#@i1#vJ7xKR6}yba%TS|DY%@xALy*qMDdp`Bf0X`sbD5gEDu76e0~K<^>xFunD^t z7XjuwTjPAQ&#{8|!~DS8#6;v_E0$r#lix z4%SC7+!>MM$8|#gio>L@r-xy(HN^g1oH)2*xmk@}X0i!5wZyK8Wz7~&zTHVxGB%zC zR3Z-R_3hpW254w#W$CykvvG|-U!^FXxDkA)blD;(q-$L5F+o8#A3u(}Mj9_PbhY~g zA>qC6+;zF9XP|KbX?2=*M#Ly@^r(hxXrJXvygF~r7>Ww&|ES|1hhYiMj7 z{cUyzNNV_)cC3tLOa^SG*P3eb!dYhk7#X_H=_r!+ED3xOZk} zWo@c!y8LyWjef6l`!;atgo6vfq`8f>>>HtEcu7x~hPD{HtqHEaUJYOScoCYW52$$9 z3>w1oeL&M)PLR6!9_?~_G(-_==PXv!{qFuuOf&;rbZ8;AEA1W{PWq0ZZlsG@Kp8lf z>Oo(mSZoYr*w?#l2!d~4d<-u`OMVY68TWVtt|1H-ziJnZXlH&peo z11U7R0qOA>=eG+Wenoz5yL5YjD<>yM;A;61R4@RyyRE#=_%&Y8iNTM=#T9VFc$cddZu4If z(|oq-0vPPLuJ}Z(^|VSASm{Z1lSQM8>jT9Q@W6rJ7yt%REg1;5K&f--?BwM3c+*p7 zQ(>LD9No0$lMom8;WI^tLDpcm)N8KWZ4`ktP_fODQQWBXPPLWWE{F?s=g{d4a#&iJ zBYu+0)okV2x3jl5AIq`=q*M?-guda8kby0LhCqU1-v(+`m!42{gR-3Wa7nn{l5MiK zlSTEZuBY`Y%`SFz?~6nyKna0>Y5lRoFKjTi^($&Auq^&t8dygJixF|_WgKuvy7GQF z2@YJi?@dzA1P!--E;t6Q8uc;l-oICRJ!{kx3fO=a5;|7%%xvcK-mP1!T@s`9-C=JA z#GQm|D{bO<+%|!4qL{b%WIb@vtgyWW7cV|X71_}ZxFvz|9oWga0j`7m^1_cFi$y=; zC4ctz7OfvyEcUitL@(zWs~+tD77 zc;Yg{V|v*#l9B*Le`xc3*c#RpEt7S10d$bZ-NH3?6kx!i1QVkRpl5v2RpZ&Jw-m$s zGiSgJMvxRn?bDe>y751k>i&U*&GSJ8isE!NmYS-_G|>VX`^_+~L?X+VNH&rtTC3^K zS5dxo%A4vl(?P_-onjdD6ciLS5%0x}hdsc|;J(|f29@_UtiWLd0LC>pC)YZ#cj$hv zad*f3_AQ}oKIH&Zz?yD%ao^`v6f^+0yrKX@sR;Q!;3ArtbcZ5yIgn!mR!PP&=h(c-)JjuYU70lc1(3?b$q z8GY7ozt)Aw@zi+w>VK7}@0ns+yJzt<0~uVEZciv8?xpBZ!h{q}F*9=nEzpyy6nyWQ zsmyX02e>uD&jP5NPVR7&RAF96w~V@Y{c1D>U@3a#@x+Fd|7$wYp@8!HC&eWZkN<+@W(Z=T)=@3SXv$WcL+hD4UQRbp*?cMvb~YA* zX~3@$;J`TCx9_|y=7rcp7aA6%#S2$DG_9IkErWiPrz?!}28QN85HvCjCkSDdqGgqtQqi-sJ09lhW`be*wYpiV+cI zJ(uoaO>jWL=>7QNbwsjqtsl+L!JlbsZLQW|3sSS)%3+=_KaDKjU}Fo`oP>u*0m*vX z4;0N&P{DhAxY9vmk@dv9&1!mD4Jx&g0_K`ZD#B8#l~_)TCnyUceA&XQ9 zFbh%BFZ0%u2)e%r!x0$)1&gqG$v}!@Eu`zvU*dXmaY!MG;0#rwFWh(g=(4hgY!-_g z27`4(g3xP%;@zYs74I#6rf|n5oq}sw51N9BDl}>mlfF7cj@9Vn6A)bt#WO$682U}+ z$%gox0gXCfZJ3RuJsz(NXC}$ze#%8c=C%zUT+>r+?}=hL2Snu#FU%e}=Wb5BuE6N0 zD~~r-=0mq`xOlvyWC2EEf|skk0WSH~%EIh44|nJF=-KbqpJp179cc=gHSaUN<(*T* zQWK{UOF_xY7s;#A1|&>}jFPgEJ48Nt7+726=rcvdpNmy@ElS9LEc^Bi0|Ej9H9QnH zPagG$b*oEh$Lhv{Uk5u2#MI+4dy~t7Ldu2eu=6bS(Y=o!V7?hEI383hWvHRbvI+^D z@YS!r-Kd`GUa;zEK7|e5-30a@t_O2h!8Sj4w)TKI#wc)GJ=scTO9SFPRqX4RA%_qi z+WsC*bK;uqD59tgmx_gTJ@zyWa0<2b@8c{ipSM5iL<7M%+^ivtISsXp5E>+$r#GJf zAyGJrC@d+|KmBO00(E)QC*xjW*BLM$ATvX_Bm7gmo8BjBza0>**MK)-(}Jj@?RxJh zQay-`jd#Yby3m1{#eHn+yulNBWC9!Swij|tBTt1?B8lm1ME=4+v1*%<8N;n@byn*8 z-I%e>p+sbyku6``k~8Zpl=X_9@k>5z#h;06n)1I)BIsMLznPTO>pVS}^b;b?mT6~Z zODw6@9pg_)0k_Bv!0bUYJ(xN32B8K!!*6vpE>{G`PQ*~-CN66cLm)M)vF@6Jl3NGC z@aoU;=FMTvG@%p4(s1}r@Y?A_jDV_7t*O&-hR~5QAZGpS!LwmOc#g5SV7p(QjH%h; z+Et-H5XQadp|WtNteo{UCc!tQsYq!^^&TH8OFF4#0X`@BT_3Mbc; zKtsS3sw5FB1`y{YKSuUFQNCin%)^F4uhmTkb~vF@b-tG>Zt z+zO`QNS;P>H@&DfDO^6YVN0{^P9&Jm=mGb)hR%OjnEzTQEo8p51j2<$JHh?#A2#I@ z3{3qkMn*x^0Ve^j-1o=2&tZ5-T)*6{`-)C&(F6Z;8(!@ZFrQ6scbOg!drPgS{e^U& zS)Ot|ZhQ7!7HNzi6^Mvm+3WirBmSKqN2&Vhu zBqMNQt(C^Zztq;*DR2KsD#2G1xnC)V=@Bw?FoT?y_*OS2^F-&H_Q#r%)@iMi)okPU z`(^D5rIm)m&lxlno+cIYzI$SsVs=`LZyv)t^5{iXTCG(WIVz~icYuonWeI2s3St&^ z?*UEwYFq|+@|-Nz^n(Is(k*QDC}8y?BzNW?5Oxj6!UC-DyvfB zxunnCmiR`?CUxJ#=MyFUR?;`uUbeWLh0t#le4JT-)#De0*qM{~G|Hb$6!Kz_otsyo zjDsB6BaVMgIxF8yqRzSLd{EQt6iiSU9{c?M0JQM$^_`Y{@OV0UI%%jJdS{G&2r~Cw z_uwP9qeczqntIb{nKwX4_#ByQ8cbyB!+x%Kv1Zt>3Cj81jf{S_WNY%jPRy|n$?*#) z;S2T;&s3}4Uq?#NpIU%4SO5(&Q#&eWM6!4J`Xmix= zb2oCfbGA@V(|^vMwND7@gbUdKxX)(!7ua z2y`Ixamd48_emiC{Z!nKfOUQAIr>Z=!OB@;?9rzXV}R8~IIXJ&h)bTpcO{XP1!5Pm zvp6E|eJ5C0+4|fGDo3vJmC&Dboplm?l&0_1%XOl!s3wY4Iq!E7B?e*}1+5jPgM(+p z#TAl1PCEs{9Tb^P799(e6$hm#&39v8?}11FgrhLAYSw+H8v`jGkH9t8^^403VVh*8 zpr^;9rzf>la5Vt)@x9fT{9E%fGo?XT&A{o?dQ{b-r$5Ky;!LU4x!glloeNX)Uyu2J zoF=ux%Rvai!pl0|EIFMp<+59QPdBL^rdO%YuQAtyg~mF}L%x311@goBlU-^Vk7j(D z{mnwx+$1k7Ogjj35b1c&3P6nyR3mZZon#0}0CFi1R2LVdp!YZegDaJK>*}kimo>7Q$1VnDO)55dpfzd19HsKt+UKDd&6qFz*U9+?L9EDhp&qSGfF6 z4)!pfh3Ion`y1zO-5oKgHeELtoJM}r_&Hu@_53JvDasPb&1olF8+a#KOcuqj9S{cq zk_Dz1VC?>uF6kQjU_U)9c}xPhea7U#n)aP(yS&=GR9Zjp;XG(V93IRtR%T~#m@fc#EMs9Cva*&^U>3sad z4+^EyQ?|T|xZqAtY>+JU6|=Cs{Pihl$#HvGYk|>;kq>kvjn-#ug!x4UARBafdbkQq zoxPx1yuDj``l&d=Y8KjCvs!O9#|q3Rmq4Qpdw^I?6)GJ)-KV~JWBYL&@D1+r-_K8Q z&sz`BfxmlvWtcRYC8jGuRYLr*vIXQ)W3&sEnKjs5?bdrN1sqtP#UPA(_6UGvQrKbpgOSxq0DGrf11p#O8bD< zxa6Mr;Yz$RI#I5uB8fwSd4LLoGW7H9IDH%_m3&ju#CKZugt^?CLUziC2cU`*5Jjap z8;>X`sgr$Ib4pN`H$K`z6~Q|Jd+dkH7zWo#C%P$rHHn=~w7+$Vt(=U=P5`Q)&1Sq| zT|pX4Wv527wENi*n|6@Euphp?@*p{D186S>n0_YdorxmQZ_n14nN(760F-@wb_5Qc9B6{F zQn+0dQ%-lr4R>9D@sNPLJU_PY<-SwKP%fqtTJ86SH#OgqzU8h=sj-Gce%MNO&021HcY)9Hh>eCo^F@r@pz>8F?D*3j8!Xx&LO_NMTLgv@rNs&u6m9CN0|_ z-X*IqLJ$=(^igJ+AF{*xHx{d$jRJ=T1LJ+H=2JP6sa(+E!G&$%`~ijOa*8`8)$OuR zH|(J}(o?n>tJ+pO@0tF14vaphdoPin=wOcgF&=ClVaKLB_2RQR%B#Xe$pv8!^La-I z%~yC66h-hs?B>Fv|885I!Q@|U;<^Jv3{gfwNVrN4 zU28ny7Xx3Qw1gM~-=x=v8>mLGdvFW_Ktx?aQ_2DwtMy)4*3Ycq_uA-2g)k;OYTJD{ zew*k4Y})4B`m*NXZQ2b%MGSl^FVM-t?V=Fd<17(qWfq)UABV^vpZQ#78e;3?fOMq3je8sh>&^gQA zWVZ(|a^!Zd4(%eOUdjR5E9iWlNd?InnC|a9G#UxY337d=>B}SO48w_Rz|W!KamwSN z33Of{Di<~U(slElwDZ(I>*#M`$22C=I9&yDNSl}D zT%C^=4cSeOE)D^J(tml?{*s~Ys0F4w%;N!u_nNb`ruM~!54HxH8SpYZ15uQ0wR%+KjdfVA}!9mx8gX@tkWk9%UL8{Ee)v8#X1d(-Z<&c!R@{2D}i!?m%Jo61*qK z1YFk%CM8&n_oYv_!>LdAt4;C8fsdO!wx{mxQ=OVYA2 z2L3E8X;@8hIdlQtIiQj7rfbY_J!L1bXzp1i-}tj1)~!9Rym@>?_PAav(`INr4a6~D z!?n1d1VS!m=X0YQK?0U(*VnBCPI`?rC3W|}!aEUYbLnd5FBgv7dm?3e;~)Q?gOqI% zeJp13le=m%&m|y(ve$?5#)zd;B^m=TkQctl7AO*f+SGyifgj#jMHOaoBY`dG|3SZP z3Yx0*5{B=t%KB|1b8M#};fGo&E`(%&YLn#%&mwn&^MxPU)==V7nfCMaZctwAFVzA) zOUzc}!@PD+^AyR-)kph4lsPv=h2=D@?FSkd+0z&eq7d-hN!GA~tx;)L1&$MH2h6YV zvU|RtuJE@`?{*Gq4fDr^e*pvLV$dQfbQI+FwMJHDNz%8#z4GqRTS0*m$GI6?wjhuI zro(-F>(r8(=zqm#rgc$7Jx93>C_jl@;z8LBpfoc@#Y!%>EDO&4^X1u_p=H;w9)_ggTLQv_BK#*TDEL&+CI4dpJVuF z2cE6kjmt*+`#bVqa8;XmC?9u-v4t9c^u$b&>~4L;P2D= zkL-<2gz$y1!x@C+I>lcz_kWB*;rFwJp(@l7f8R0xwMP4!uvu-}<~&~m?*Gp*r0s%d zjV9hokp0WI{+B6S!1KhwSr~$0j7a@I#~_ano-O|Wx_Qfv%%wg4*W_|ydV*>rSCtaL z3zKIpjcNZp?KL!zclYZQyfY=3IsL!KZzjZ7rGGFRHeuZcac5zo9!=A#)71|U$h&1P z{n-VNrv%WEisq0^v-!3In2{m+guH$?XeW`h3xF2j$0rOfT5w-S+}Y8zxL?j}{f_Ya zqYJ+;ShQMG-vhUT7+NQbiGqo{!$ymXT~0k8v4;jVaLdi8c zb1)^=iLe0-%`C2CS^41ZYq~r1Q?22uQ!IW?(~IX0IR&tUhj$4O_jFh@hpz@vS@?E3 zkf&VFM@2v+9*{12e6kP2XLBU z<6!8)3xO}(1|y)sV7ggG^kV}i6(0`!X?64E?e8!rFv_p&-FHk$;o>NRM^IgKGnyuz z!2u40m>Z$*`|53X?luKbsOloh=%ULV#e4&^$Fcf)^$DMgguHG9>=iF0V>ItY5b_4b z`6EPd8Vow(DU@m?tIcp<>?K-A1A8JV=So}Hnvww=v>?U7a8)H6Y6T}K0EloppTB>N zRT!(<&%)A02}zv~`8;jDf|DqMj>Gxf5fRW@*jRuewFnwjEWV!k7oP_{UbL)iYEueZ zCv4MiZE7{aLww3xbbLuRAP3r)V;QH)6Eeo(pI>F49uD@iS|e|{J6z3?!aXwb;->*d z8h~dL0whKj_&mz^XSzba%+(R28M-hGdBdS;xB6qn*&Q^YuxK5(0Mt?XDR&YAX8-e< z!<46OBfC(ebSxU#@AqT_b-9D<;#c_18FpaAXoO@S7F|NB-DLj3EWYfGI56w_c}Wt+ z1=28e0CP!|2tnhqEr+lznojJF^g|U`cS*DY--;gKzQ@qI8mAlDuwM~$IYaS=%Kh$u z9W;I94ZB?p8a|q>{`3WLF76bx>REI>QwQ$&eLmPJbM%nBsr$v`HSf1aGeXH7^tS@(LZsQFv`^gm_ zUo`;_f~q{7SbBVF&4O=9ka*LMK%%?!cv&r#z*0bHc$qu)Xy8G26<-a!j`E_UcZq1T zv4?qi&-XN2>*kZ*%I5vXe>O#NzU`H^%9XCkaGpVY@VJ{BCoE7jvq)jQ8fysZa|cL0 zNZHgC#Lttj;z9~T3ApW#m$7Bur_U+1FcEHjk%?oxIq8AXu%GFQeq;XY7DXhY%wb90 zdMg>(^7?AFuuQ_?sQIOpQ&h8VkFGS=NnJA5={O5J)gd~$EUASJGO-niCa{OTy+il6o6t?%kPSf9J4_aVoZzMuzyrUlvjBKHaDNZqeE-sMqcKu`x>t5 z@EWdPm`Q4$5EpU|1O|o8{F}&*AINt-{b%#FUj`E8)V52s=-I2IlaJ(J6-_Dew zRlog#>~6VcuZBVDCa8D{RV5gnUm_;~L?;JkeTOu_F)1Xb62 z7eBRvyd)I;H)>;!z3Cmf(FcAfj$n%WEmbrWVh{9OM^=em1bzZyYPdzG#CkXW?7V&c zd{ssr$&X3g6xRwudsag(*p_*#o(F_P3%9*~^$|hxgI6vOcd1q&)PZvC4oki6L?3p& zDBN<`o78V4>~_qIsGR&6PQ)OmuyntP>4!gQ-No zIYBW|1z6I#Uw0gt#wzMm&z))wcaEf&#=XE;0&%HxFA)syahonybIVn;1?WyRb(-~~ zSXOsQH1A%$wef!h43ZaK`23~q}8&}>bO#9%dv{p`hi^VWx!y^=fOL{>kKw|Ztd3X$q8 z6bbL@)y5uPXL}q@6}WG_wpw6E{*>DSd?_pBFq!Q4y+Pw4aQ5fwpePhKG2QfprB(r` zqdYwTJ=b>Ewn|G;A7H+I)kz_GaD>2%@^Li!_I!L8e6lL}!aCODgaXfXJ`y8>R}RNa zXo~yX75XJNCc4^Rl^<@CRc`2X z_oh5f0g@+8df18HD8g|=U~koO+AkUj#q@UJIq=cVACm;|BPyWXG_Uv;>U5%e5MQTx zB|!J!z})uty4;`Sf~wvyaz59C%52T*P0ie4$*0_(Q?2f!SOW<>Aj4ctXa}_@xH%Ae z&+A0c8BhlP*tN>dl3ytSvK?om2S*Sn_u@@@9$$r~QzXl}K6}_4s zZWY_g^c7-N2^jB3WKv|mv2Eka-T|AvBpd0aGHEY`11iKEABB8P6&vj_C!Z}kn{2vWaa)f|v4L&^T1sDUL8Q*?+4A&&OWoRi z*%e6_e0{gHL|ymBj8!h9cPDY8>|Dv}O0C-HnQOUvZ?(pE>pO!ll_^%oKZdN^NtFu3 zHQ%_HE{nV=uHGNQfP3-cuD8!LuSy*A5I(-R^>LHUgZ-c?DwOxJ&Meo@(V*dD^&}^q zmjU&?>(c{ULTLOs!)CkW=K3sWL6{7U+#<`>Pp98F1EMR`!HC91c>`k-(8h!o%`?%f(6yY_Lh{4Ldc4d2 z;RV3U<4+%?dc=0TMPCkGkB%}ONY09it}4reDF)(a`%h4DoR^I!LZuD66}n*uIL<5S zc6%KlB|HQS#=~&Il3S)XrIfp2Zpj=e9Ci)La`xYo=~g*sWGmbsID{oHa}Q1jHBve3 zW>H0|Dxy2Ec>UyXStxB5Y9$`hBM0ak8C4T;ym2%SkEOR4B1CEqrjCM5H`Nbjv@VW3 z6r=Od>a1eQ#3RI|+sm{`Ol1-LvLs_b1JarL&6o7=U2LT0T8I$zr0tH}_cu@XJI{@! zW0I}9`-nox<@z#f#wQKne-b^T=^MO9;qOx$5m(9Kl7~TZy$l!ptj9hn1DzKc4a1To zg8oH;`ei5Twd)#RY*J}6K( zk}#>V5G&h5-kCA1LKtDDi^urGu5HA6*twvVq3@PY$EaG&bqnM7mqSOXCv)!=X*`V89u0`k@2(J;{jEdi?=;^ukc@~Kkh0G7uK!OOItnDS2|Sc zI=6_`{9V5TMUHCqg4=&UfXxkOZ=z32Xw{;71Ql6s+NM8yViOO6E zwQ#Z1soYUR^@KCmWjc`s}AhB*5`W|%wo zaocl+#AfA@aw$DNnO4(+F?wgyZ@m-Cdw|f|Nr^93!}~L(rFJPL5s{hdFo6r&&HfF| z#uhZ_jk4EmbFz#2CgPL-kC<+?o(E%=gZ4@>7$Y6-61_+rLT_vDzM=+^F61#eVnKKv z1eT(8LmEq}g?a!cWbPAfhof6e>za7Zo*X2lK);0ixSa|U%{RwWM|893>lmz#}Lk#JMkHy>QPV zCPWmvPiFZ7?;GHUUVbtvQj5=dc~SY31Cjf!s$-c4f8%353frp9&d2sIfWm`G{VM}@ zjq`}MTUR^G3xQSlwOb|fmU7?l;kRF?PmmJ2$TmGT$}p2yjwdN%@*E@V?JTHr=d*!V zWU*>rdqHXJ{`QNgqZU<^mpK8NQt@^9B8@WzmZBi|tM>n8s`dTj=elF{ zIYYfR(Q-%Hmoy~p4AlKGT!O~>eUVp$D4Wv7pY;VgpispXi^E!AYVy+(K|5eLo=faC zRhL1cn1z}EXv|G-G%6x|iw^;Mu*+KC1_HKhbWlvR6|IEv;m&$5uyH?hwj`i;D0s=D zqa)7?Affx#u}QMgsuxZ|{D%D|5NPgwQwZ}xUIvMnNz#RN>aTYQk%dk!iCR*f=-BBm z3$8PpanUbBl!I8LL#E=^2hT>GH{*R=o>jCS)@|W2rKdBdkyEMY>0)dSOA9XasE@Qg z4_)-)?my0W=DI}rkr{-w!OfN!9T=aAwY%iMJqk?J~K82p3obH-bReE{S^1CbIzaTtxdFvDC^jBzbJMly?Uh$oQmsDy2LN0Flo|-2kLXt zUIDHyQ{^%`X2Y%Bo7A<5w1B{cn~$oFUxV9AZVB?+>G-c%E;`j2{>n z=~TAyt~Y~#wuofaet|_8%5feQ&ua16nlU4YocM*=@3ZI%v@cE1_g@IQ^^Kti`N61@ z*!$-fc_Z+OO)UXJjrmBx3%Ab=gTb?ma$WgDXwz9y+^^ntNuWh(9Z z*H?t9c#$fnN&^`EcqrR*B}$*6DpkBygNR5|!If7E@=e8NCkURl_n)9Fn^FDjOB@;B zR|~;1#}Gs86OWJh`Dl?mx!;u|!qzr#un<4%+YQQt$={jxDaM?`nW5K0%S2}k*wJG` zjcERfbB#na>fk$3`9+6bOP8fij*#gYM?O&njYMRrua_|rtv2hZ8ABS<4rSTaQRDSwVYV6E7FVx-aHkNqPR`QZVV5hANvV~X)?kU4Z1vCHgH;tZv_evppDg{+dAjV?F~m8M5z-lI+$Dqd zFzyD$h`U~;n(;Ab{nNP>d_gXtYqJyKt?s`nCZY;67gmCuDeOGpSdqL6Kr5JcFnMiE^KBq^vSj zEt+#prb1^pH6nhHp7ZBQ_awQPYsRqXug$Z(Te~H)^+nF?2j+K+ zj%)AABd?bUvK#Dn?GJ5(O?HepW3hZv!#S*0af6>hfpnBA>dA(oqD(#XQ7`~WZdcm3 zVHTN89AuLccsL>j_-r}W+pMEXwXkj*G=?HdjK5f|RxxJt1pP86_@ZY~0HLktoWwd3``(D6C7aedm7}=KR>U(()IwA>&`@iac zT%Jp^5-#2U$n+L)*M9(O(1;)=)9GG-0ztsE8>TMc!ltu3hT^h`p(jDES|&v2wo6sPiD}`Ah82YM9#T?t{K;gZ$Yz+(M6E(05rhEkg&$051Y`UqY)PBsDuT zov-_$8-}R!yjY_-M>g{|(00GhiUXJ%T%~c^uOblK--GNV(4a7UBR8&RQ5l{0dRL3< z>E2y(Yt@q1*MN5AC!z^7Xw9S7lpL5!Vq7G!y6R7Y^s-3Vyz!Zp?Pr-G48e}W``M`$ zv_Z94Q5>e{=>aZ>PFc1-aw!ZI_S0Snk&^e) zTv#q8J}J!)gG4=XLryk$tE}CW5)T;mDzlxQPj344Jh$6?>{7ztretTpLcB0XxPY2u z`XtS<&>K{%XZ}e2%SPJ3mPqz=i}xuyuYNnjhD=~HM)h!iwwif;s2@O#L6JBN z@tJO>X?@-xdyOP8h~jFJGLNSJ8iasc9Q){YHuOpjLvCu{>jrd|KqMPzWHF={f(f8H zSj~`QAd7W!aod0p@b&L@PnXVYGGFL1XYTYXalEAJ4}st}U&i9%`*L~ccALh3xarklsrBhpc<%w-=>bHrjEc&Z7mk$iuwHYt3+iSCcQW(gy6L~PyS9B@TuBK5&7A@74qEL~uJqbaS%ZPiV(I{q=%)0Dl zJqi4lP`o}aoXsC1G%*$obomKV>|3 z>cO9Sg(MDmoamcDTRj9W;!AyVV~G(F=VKD3aibsj%Oi)&MsBu{VfpF3906c~tI{7t zsk@Sv+UWk{#dFEUXVqMa;(0_6LBzqSpsWPNPyLaADeZ#@EL+~LPiU%x##4>inW|N} zH{F?#Y|Yj34~G5bD;`pWGf6Udibl zyw|^}eyaN(l9-RR zf?Oic@cKBS!Gg~$d+O6GJ|G1!!O~*oi!%H5BN@COU;UezRvn4^)PrJ z199{hI@^jO82ecfM}F~7U0;+m5}Y@&V#`t-F|P zMB+Y2*<>4X9H4X1)FCjVR!i4-^UDNu*3qf>_J>tjtx3GXxYUZDq z^w*Oz75Otoz<-e~kn~kWbF)y}1G@@5jXQmff^>}5$)aUPv>%>ajl3_Jq#o0fLNUK2 z;;YlhPw&Xz#+;j69rmD+rwf?eDEkmWx%Maya<+#*q#Y@f0sWD}BvNK9gv>`j2*e%0 zk28dNi>x~9>M$?_kVh>V7B5dX8hQ^v`S&~Nmm5Z+fT+WPj+iMDXb9tGKsKLG(qEkd z47u^R!GLr@5y@1Z@!O@RTg@eej@V+&BLu$$IPTp+KVrhdcw(YNjJ4i=YB_0h_fX~^ zk-%wHU^t7T(%5VCR1DNTEjjt_JDGrLmN-${>wZQ;x)!0H=e@AR!jpMf%USZCHL<)c z@@&={MOjQ|e_DYKQ3#k5gX}B3P9Yy!1<8|)YA(N|U;6sRL5cdZV+PD50qO^#TBYjA zY{QYb@nJ4fc@Ww^jYsQ7!s1|%S|TO9g{ur+k&fpM%RN7!W%;t`b~iWG>suaQY88pb zxf{IL~Mk%7EAP<0zmKc4< z!GiPUd+Fo}oh44ZJK5DYVy+d% zi&oOG8&QeKo>i&-r!^-G;%tzwzo+bL{n@IWZz{LZ3zwY>I+T^ic_?mb73Rf7EnDJ3#Rac^*{e%Vl5LaxPJ||DI))C}V6?`_ig6ZrfT6m5 zxtZD656~j>UPXv4>q3TvH9*IC{g{iz&CEaV${^g=C)=M^Ru#&=NlB|`DL60g<4fxr zN8W^y!$mo*fB>%M6vY5Woig&SxWm>bRL?{pr68dMwKm#APh4x2u^&cj9bPXAy*U)F z0Z&2fq=r5lc%W|qd>Yes8Pba9V(N7ZC8@j$m#^G2QU1+LuQs>4l)g0Cm^FBGr#O{e zww!`?34L48rbw=TH-XbZRlS|E-i6)VYwCl^jp0vTTt7x8i;;OZzN2^IPiq8IASmu4 zd`OUZ6Q?HEL65`RGGoY0Iy)%8sUuVBI$9Rf+GczY3LPDii(r z6sq1;hV1%Wz0m&J;wEF5mGFLbf!KNJc2Z&8mqR54c6psSXlSXZaIm#~J+2>SEntnA zCBFCVD&KdN1Fih$^BRolG)c(H^!#zx`$Dgd5&B48`n4VOzUjZW|{5{Gh%nwT5{(LIvI9r=|*>pps46gdzv=6;Hsf%WzE zw=M4VEzn^=Dx*A|HCdB)xtNIc;mU%cKzUSbLh!sMFHNjM_)I8ad8r~u$>P6qKt`+v ziAZduwq#mkoqEE1Vzl_;hn#uJK?+W|r*P3LSPZ`(XyRv68)f%ze{e>KJBzb2t}36H zG=(U2_$Fl7I%AypCRX+Emb_qOYkrC3XXI0+US*VDaT<;=t?=t>tbpJNG#NCccTKLO z(?H`w##pd-{tuVq50c&(n52BPFGd-r)XorH>F0Xhls+AIOpxL%{IH{-2TeRfj`Kg& zN{6j-6XZY#1U~qGd>trV{{PT)4%~Hh?HX=u+l}pH$3|mg2W@P#Nt&dwZ8eQ;r?G9@ zwj1_a?-}F#g`Lbf=6as{y6*V~k6N>|V|eW2B;$Lx358+~8FEgUt&u|Grw*O7|ANk$ zhlJv=!ma;4hK}>M$zZFBHXskgtP$#0^<&ArlMUv-#ceDBllWO5nr$ErqX@2)6tq%i z0M2@qEo?+49qw(*{cLqy9!BCUoKpu5qZURWI;e+SxspzvU577ji;4nen~80{NYMr_ z4C`}%)_KaVB?za}`{OwkMh809(Yf9{cv|8zPy%{m*baS$26+5%W;|4MF{h^s*`7zZ9V14pG0a-XTsqLKvjzNSn^Jpe(W z4xkKK@~V8DLX!O(C!Y*0A*YHB7=g#ph}KxYd)0Kl1QI}3_h1fp(jBZci)yomtz1+ofy*!?n1Q-_sY z=#o(rd!P%1C$dOO?MoDX{`XybqtW9c?!Rh8fd`35R69n}NU|kJ{t!%6ep0S|T{W=g zwG-#UCk0rLA%8weOwnd9qj-w_O`C~>Biy7(89@YnvKPZ_m{3W`+L>>48Oo2b#zK~X zs=Vs)E0#QtWb>xOW@Z>@<&r|7D(Div(bgY@4`QY^8WXOuYP^uZJsKTq&l}^$zv|x0Rg_1*4 zD=5%O{-S917}BjaA8e^9W->}8Lat+1X^vI>JG91=| z{bcPq3=SNI=@vPNpmgc+)}}p7;vBaZKLnsJgu7HB1-~$hcr@6hj38`E@upNg@?eT6 zP2&bZk3JNb{6e#_!{d3m>H2=uw&4*5WL(I6g(9HNEr)6-#VhFdjcEf)#XHu=snK{Q z-rkUM-U(d=Iw)Dh7rS2@+yDsI!ClgEpSdScSZdMh2jG}AvTg$<$yrM zF4`R2Qb|?X#(TRy9)wRZEeoL6!r>{ZZGGT56j5%$X%z-tlks05yFMQSu||hs;FV~| zpll&4n0E`jh~Ib6y4u9Be+JPaSacTXL_s|#Kpv;{zst9{0+9g0Gg?q3QBin$RSpgQ zS9eq?(_N_RXpX6jjfE1`eBjV?*30tc69-Ax2+faU@hAd0P(Io)^+2>**9uxeLqiNZ zW|psil3LjA*;%Gmf8Gr2RKSm;X+o|`NjJp0CsUh@SR?nP-Q?{7N|$;FOESis%(a6I z8?W#G2rqJ3>nNcKbVLg=JkvNBy}M-U)YgD-CJ6*?JDx(QSanm{qsE!9Rhg;{Pj`)x-)>yEWi;Iru#@-9ZpIy zaRGx=229(vxTqQB`oE-^1NjecG;*bCRT?sl<=j5i@9Q_+ZC5NP$*E7xgcwucBi$f_ z8Ce|iZ!Ia9LYFN{TsHuzA?__n8tJi{mSo`Bh%*aMZI1vW;iUFRmwLQ{8xCkFIOpqN zR)%ctiI4+Rkz{p=A^AxSXE0J+>kk!~*MkGz&_j5!YP74fr{TlT9h#N^S$06M>6cAl z6OG7|Lj}S!HGex5J4_okRi%Ynelh(#Xut)m#C=@#m^7Kx4oxGJVKk%RqI}2q{J|kR zXVuCP2|DkCVR+cl8Z_8XQF+N-Hrj(JB-E^DynBHIhEmN^NI%@{MwpgILvTvZnKfQd z`xbCupmJIij%j@3jC-(Flm6h+dkoIuXnUiSMz9Sm3$6PS^lpawiPmLrBU|zKP0adPoYbMlj ztDHVu#BM~)E==c`QVtrDi}1+7wjls6Ml7BWi4~Ct5l{gv-2>5oLI#WsJRvv|1C3Zo zECog|1Zb^Pr7Pap=a&B*+9=mq-3_$VfH%xR4C6X~PV1MM(Di;{pi)IiLGytiT$eWC z1=43i^<-aZ-uf_`5qr&wwNQ~kCMtZ^s54S^bV9CbyNu`_G~Bn@@H~2Fk|D=8-BXa{ z2@?vmZY+}~@EyY8%NyOdRMXB`uMWK-9ZwUX! zWG!+cdOJ!4s~1Nd{}>wD+Kn*??K+AbRVgy3T{O+9r|%#{s?MzW$_14m=%gH)*hU(1<4nHC@C|GoNg_OWU+KMtWJ%+jR zA3=p*y`3v*oiRDc2vRMlS9-lZPNoD{?)}W;vXZ4oq)4v!+4KzV5E1U31Ag=0#R|Th z(9$SDrA`kE251k7wf#As)@ng-ACOzv=n3dGNa2;bB#$EhTsYCRhLM{~`Z;pbISjjX z$f4ftZxLyB9Unh&)Wy|ZwjR)CIc9++hGWYN-cMsM!QIQa5&bIT*cR_G3_;F_X|IkL z?19rzPgB|v4}MVAfBM138vFxM&my{b%JbYDoNpmSKW`$pBJU9#X2S1-4SCSwl<_&j zPHHT|U{2F=gilM{t2+)I$B%$sCiXA4DA*F^TE$^Zb*rZF{v|VJwY0(@t<+scjgu`l zo#X!ZM@pXTdUv<`nZ^h4`-o+ue{oc|NN!fD1=50$1k2L;eY_zuaJU?eKpA9Au(Q7^ z29K-KZ((_tP_j*ivlfn$C_EF&YbwTJF#JWhfA5$4n%kRgD6DThH$*|ObtkNUK54Qm z57P%LiR=VjHrc~O!Z$MtJ8VLFk@pv1V_f&9Q2D84sAqa`)l>CjY@0Njr?N1g$J{fa zSt*=5&JYy3!-Y20{am28_$+O)g2sjPj>$kZ$UIQbK(9puEtkP%HQjhd&}+vK4;d}; z?>Pp@dTmpC@IM=!!~|d&FZ+o>#EC{xSm)>std8U9lNa*3lp{z+q!}{M*S+dlo8Y&t zZ+;6hxdOyt;-%oJZ$Zps{$eu2q1`1Nj}74E^eSC4tfB0dPo>oqK<--rdylzZ%U{``uA7u<4?N&+G_U8 zMKQ4jN(vc&!a|)_8OI{UC*oJS!k5fH;WHnOF;_rF%XqBj#Y-ufQ$Rj3Wg6@Egcp~U zt5IklN9^tC?XG#WF?= ziOmehp}}=-r@|+4mfH=6CV0^)P?f)+^Mj%DD~CgKEK-?tbQ?aM$@p`c;&%W?Wm~)H z@S3=3u+AS+o}B>o zw)k1Pp`5ggk7+T(rvXhMl%~*q0ZO49JB6C=3+FF-V^%>v1+P%8asB5JZ!?|14;b#~ zgq(G=`Vxs?sSUa8y`!!7OIF~%MSF*V=a77Z2`C{! zB@hEchk9}T%7jBZJN3TNfk#fzTO+1Cj;A?K5WkMDt>Tn#g1>R`Cw_N98b-txvO=*G zmDa9d6H9XxN+aw^hBs^l2#w%7F;Q`i*j|d%}ccaD?=%28^ zN6w(j&B&s9-JMA$)=y|&BlGiUtBr?+uarpZlA*9uKN+GI;_vJ8CrCmK?Cpx6GOzDW zmn7bprgPz%ruIeaFi8z}w<6z^qT`@=M#)d|=9Dd@bH{KGt(q+Mp%+|R*aekXmIU2Y zer4f;M2s+d(YDX=}hk`~nn2-UQNqI?B|^3Qpr)CaSZ&6dsb^DrO8 z_gPSDlh(g<73>>6X-am7^^b>)&zr2J!?4G@Xs+LZTLPG=W}|h6!f}RUFv!BC@U4q5 z*My2R4k3DF5D%*njyYKOl2Ix-u>7IYRrI#sc6VBC%XyNR_}JlJKS%I^Vq&tDG9PiZ zr*-c`TK^H0qu!c}eL}Ph(fATHUWX!+I^k3a$gR?nX?`TqpeUk{B~NQ*Mb zOfJiP`ZmGtC{@Aawq2O!Z-|i%AJwmFs=~oVP`XIFk3nCv7|=k`Fb0p%%KD?a3#g7S7yC?IHlE?IX;Suc?H!+ z6tD>#i`}tg=1N5d$_}volEC0zGm028o_iC`MZpRkOr=v(o(iFkjth-Cs1gt*Nu$L@ zMlI#q*hHN5&t#?eyJ|Z)Z2tP4d!fe(mk;iaiS^Fv8K(R7WtwMLghyQ@jMsH)5`LwJ+U_3~C{>laOFkXiI|O+HDq- zoCLgdn{3lbVE)FHM~(PZc7-HF#A-p)u+O-HR>gI0ou{BF+3n}WHMrhREyGY+jSe$( z06iCum?X~V2)d}q)&L^*{e%z)|(d!*ekKx6xaMT8)K{X=Y0C%M+bJDR!P z-PSWmM;EgmDhDjeBxV)CNKkp34Xw{W>(ELp?|*LoIAFm+3M1xKnC@Yc-G-&bZ+^jW|=U#c@R`tI`g7;hw9 zvxvx-{!JMS(}{9N_o1OLAjss~_{ZGbs4Is8kj9#i&V8w6<&91iTZMy-gn60|WhNpJ zC^<@EjN!>I3FX>j>(M_PYpl1(nx?`kV;g@uOs&^EVwsNd_ZO0`6k&K(GoNH*6gPyF97dG-ybIId3% zZ|@^x!faBS#$TeBk?!ut6*J|q{E$?(t!B4rg1I_xG*Fo6hfExWjUHftcXutAuH`$6 zzJW2fCt)8Y9_e3}M*He#{?x+v#YAArWeNq$pk7S*6U$#+-gR1@V_pL}7Tt_W7ndmz zy4lZN^i_*oDJ><1RJc6^Z|EP?1(Y1-)2@el&EN?YMujHiRU8kYOiA=0mF#4lmX@2?el1I@Q-&8PZD`zow2l2dB$a? zajb~&v=$TgEwp3jv07gUv$d&G3lTR4qd!p>E@y>NfCa(e^@%`z8j11Y>g#z+3O~kF zk2%HNDHK}&H`^Qkbu7PYFD%I7VdsVAA=3a?+M2q`R6R|6E!12JJ_k12L8fMnWX$@g z0>d;WBX`m9M^TG+LmM=tEYBrSKSG?DHlbW*Cfg3m*ZT6*h9jx6M5-g_>x^r6nxFom zjhJeLASPQ^7-uU{HuZw@_^4lOM$XZMM8&cpk)yYt(?H}b@YWQ04G9tI&H-T*oRtyI zMdLS%)mcAGZgrtVGk^2qNKY(#PJ%Ra5w*4(f2GUQYw&!(ggmX*uD+w>sO3edF`pQU zdwaif!0<#mNeh|_6MBo*SG}wWfOi0_N~6L>6+!Im&&|>dpnao%8Qk?O9oVx(PqQ}J zGu@huDXU<3l+##U+dZ^_Vq)1@QB{B9lw4rQ3q6y@zV~q*y5<^fFbaX|J0X@~L+3lu zcwBzy7#ur|nKGDUFIaTxDNtNKcATc9tB2Yp)`QRqyY5vhU6^Dm-=eN1E>A5xyz zg>L+!ErpuYoH*h>^GMhIEPUjXyBu^-p2@SM=%UC5m0r#5}?x;(qX-RqIK9{=<0qMiqcvOYfYF4Mv9^*z0M^s z5#=@kHT}NZ{um5UngYkdDJ{p7%*m78!&uZu4mZpi6dI281RB&twgZl#0D<$RH$M^dtCE!cGPG#wg}c&X8`4SY+Rdbt#k2Bjn&=7YUlb<2Rk(g$lS zXql_RaoOT`ukCE(ber=GeE99re&9s|KIvx;__y@HdwOlgUQCLItjO7GYAsqfDXDr& z%V|YAckDye){PgXusud5xg39 zVnnA0>)2!57#Gz!X7f@$c4r+UtLL=8!3d;7j($i^LvSj14*Xg_`f=*um~)S+B3v6b zlyy+?THVR-4*0wL;{%GcESj*tQ$_5epsnyD_0XLdF$K6VaGl#fEmu{(l z$MDHgr4&am=v*j-sQh9>xJ26}M?V`EDiQl>HCvHrV=8M>R2q!+mLKLTm5pE!Ij-Z_ zSav_Mi{4@#V-tsMVl4k;Z-A9f4aK0O4~L0$KT%Fr7sbx?W4E}Xb% zXyj@5SR1@Wla7O5^0uoDLw}t><||2nr^lqwOnT0t5||dQjnWitQE_xRr_AA}#PcpG zpp#4#k=vk9OHSh8e{hu#%GAdSGqj}Mdc&Mw=nwrv0)P~2As}H`#foom ztLTAZx9NZ1h)_-+sllN}5_ZfTS&uNY3KlgaFx8tjgO(UnW)R~WEdtx7cNxJifTU&Gt>3ef!l4(PYG} zV=h$nGF;k<;iFje|FTR?csee&bC=C2oq3*?(rUAbQv3}PL>>NEVswbZdSW2kNEbGw-oXby)v zQUzo!iT&NGu4tY|UyavzxdFO^m$WolypyF+=i4CUTwN%4e6()wBB3a#u)S^;k#&R} z*Kov*oCnWz!N4Bel+7m4J6#S#*s8`M^E8sOnqr}p^MEj_k#%s_@ULF-;dE z6ZZ;L3&4F%f^i*}JpDmpmx{mmJ3PGqx~3hKQOlDsaL^rPuYD;Yydq$7j|o`G;cDsu3OwBjjFQ03xt_6&D2-|Sa$EVmCBkahE~wE=7`|?q$h9Wxu@kZzCz0Gc>pEbOqAd9hstIi8ipl^e6^}`}hPAar4g)J$4ay-x zc33Ti_yA-5CLGwiWB`TR_Ki$mIKozefsmQEhxbL+tIhojykbI%7M`SY@HR=TdgiaD zO1PfR1{)tzVB+tNHlyr9keAtz)$cM`aWhl7a{YD7l8o^LE=bsu8xPNVm~ zl7#_gG-jD5z3jHI8FKhy8*cyJb16BBq6er}S;U&Z|VmNR?_k}BL;QWqpXdhziL zQt={x(A7=QGSI&@)eC=GGQQd@HrLL+4`xk4db(nYaCZk9R@mgIAb<6ZOxL~z%Rg>3 zztj#jS&;N@Sn0Ex5D2)>!jWNb_77!J7?Ds0BYu=HfTWbl{LspEx6JQsEG@X|S03j8 zFIlCS;mK^_w-?(%Pk#@kbB@l5mWMMWi{{Goxo;I@5rQBI>c}k}*%U-NpcL6OXpqT_$7gfaDgnWqeIr?Tg$er0{ zD(m^NUsm1HXO*j>*N#bL6H85pO@V9hx67q_wW;E`3gqV|q*jDYp^zjwnfZYV(@%9U zIM2sJA$NQ&r!RrX1@1wX2Do=z6HHYS~$Z= zSJ+An(x)+@uJD(?*t$A4O4;VJ|C{99Q+IMqCB{+gs|UgvgSb-Bb&wJVTH!!__s~O` zwO;F6g=qbVJNelxFX*j|`DkM~jJ@tRW8>G(I$^siC$*5P+}_=9W!4p&wg(_+g6hG{ zZ~V1V>d|hO#$rU$=P?LP)%;FvR>xG0cJMlsY8LEoTFBIn5rv3R7XR--xhUgw_-vP?`RNmOzrX=7ysowoFU zO~~a6z!k(2^QcIAU-i$?t7kOYEzj(CN6pWorp!A!;+h@}**Iq=81Q*N2+3r+0F0Th zU$Jn|&dGP+5A=^DeD|*Kkzj=}}HB7|WD0yEwSF~gt)C1Q_b@vhc zSCAyGL$uh~78IyLkXwrw|8Xby>u&l_1isn*ixKG*m>7@nxl4pPE8lf4|U-`RGM$3-bF!zd_qN!9MXJQ1pfWKzV8V5&FYz0l1)0X^jFR>f;-?(a^I^qOirQ|r z2dA*Tn<{1;P?|!Ws)Q8lFt{+(xCecT3dV~!TPvbb_-#0AI>-1tp4P+nP1CfT#4``C zs+Y~Fzw(~s;O6DTnb8*Ltkw%dAO3i`Y=g-pR~kH5-f49*X>v6pXBe|(l9*dJR<`s}zmiV`w>8Zb>vSg;lTDA1>k_*+B6c@E@_b!!Yh({mIdvW(TJ*33d4rNq`iEKO@O{ECN*+ zsqxqY-6}vLFA=R^<@8|4q|tIj^!aw+Yf-+J9w) zbn=^q_^?dWc{BOH?B{b2i~?HL`%f?lrz0w@Yhm?l*FP!7%VQMnbVreiu!F4m^*Wfi?M$51vo~@k zOXOBEtfwaiqsLN44~+VBG62_}7?pz7-GJAqPh8>wyT^5;d@4(0c^((qfJr2#o+Q?e zwmHA?r#AB3xTq%8{J|T3aZ;_}H1^(7l-@=TZ)Ux5ea1dqXp-gkJ9hM6{BGOr@rj5D zR_ju^g|_#j%1-E3tfAX)3|d5lx2ff#WA-#^D#JMYR7jt3!_f>a=Jr1{ZJ`G0DnyYg zDWX=+5}`BeLZoURtV2i{2Lry%nA{T#eGU$U^8s?A#>0MOy_GN_(jkcWlv-&&rVU`~ z-_2}vp}zN64kK7hy10^h+s*76f`laavB!DaWkEmOS$(8AMa%Xq5JEdIGs zPRp|adhw8+ipRWd6YW5qend>!$YLEWRw3UW@j+5h0|~2SdnP)Zt68P4($&(i-U~51_2O@It?l0b~I9Q zhSmz$x}t^y4W5lc^(Xx)LTtkut4wc$k5*wYWn)3j#S&(SMM>5JQO*9v{jwqj7DNXi$E6263h@7Szq8>Av8k)p@ z?#o5%$0X|Mk2P>uBFtbKzcMEX(J;mBJR;ct9F|y(B77jkitnM9ij8`Rz#-DKs~Ck^ z+CcQbXwZRjKAzStb?}!bz&D|br4kEyuw-YtR<8Bs!fogZspB3x0w4-*yVbA`c8AU0 zxDs<)1E5m@qa>|T4sG!3x+4&9-<%V#WMottt+}bI1{{K!I;|$Xt)o(HLF$z`&@?yY z-EqyGVPHN;9F9b)fHBoIIVv8>jP?uKNxZk8Yj#g9J(MNnMwdo?(&Mac5`0VME7~sg z{cme6lGp*cF(F~I)-1jDChGsUmkF^if#2RNc`g>&=;UxA5o=W>q=)b--z(#;A8q_I z#tgPMp2p>$ED0lsnDfpj{Q|lfFGk;G;>q16((&DPb;E$zPw(q*>NyG$1od-*3lE z;3dfsG9}tvt)}2S&MKQ_;pF1=FPCpDkqlhF0Zt3Ma{$U?*5$BS^oHCmNc^Lkj$dfZ zKOu`1m@BR9VsOLrBjCukv^^oqe=oi)chr$Hn~29g;YW)tR{c2{b7e%hLKh9RI}9|K z2teB>0qSDc5Zh!-VaLHGk!dyqh6M)^>HB?NYTqCFDI1DkS~)@R_L>N&hm@aVI z1EdSfnfW#=gw9j^2UwWT2<>v9P>MxbiM*IZ1~C&g2&Bd~HT2PjhcLo1!++8IP9r@-A8U<}48-6rRM605P zP<4C?AsKGfHLKEnkf1n+OFOn~KMDU4Az<#_9VIS2(JD zNCVmni1>ci^6>{Is;%%7*Q)#xhdn^~ye^f&#uFC|Z7wA*|eNk%!bwCP}YR))bW!k`9#3rb+ zf|sB$c3CfHG)AMM>T~91Mre<|nb~WTRpcpKkdW8xO^LG99eE5f8u2?pemeE~`;0 zVoK!Sa=|A}xhm3KRJs0)dV4?s*}wor&648cE)!61aMgO$gQavVmdC)tYi!vKpC#)3 zMnn^aLW61nldQ_}Z|H3&m&4Ops_Adzu(l#SZzi~d5AP!an}}gg!8y{NBSs79T0#}v z!Mv~NTqnbWo)Rlc5bu5w(_IDpD-^K7VI}2MeSPhwmIr0ymjG@Q5@K__dO{|q=GJd% zQ3WVNYVSbAq32CnvSviS<6gdW9o{~tY1-Mtox`U$h{sltY7036<;x-(aswp^QIUBr zroJMtiW&(&aj7r!Y}uwy5W##O?6iQl<-yc+{Skt-cy|W@xkOCV`jT2%fuU;sctO5; ziFQ4vrkAz`!N$eI-ZfAj6Ko*rk7&34eXCD0^t(%@Vs7u>?~xNa-?2?+)&Y2m$q{QY z&*N|)b9!vPDw%vx5F$8DRJmdcD&5?MZPjD!+aKzzQ3}ihD)Q2kI8I|yBy$8xMq8&k ziryCvGX+n$j&#`|Bg0Rtp&*NY%eBYag#Ij|I1m3V26(lH*f`aUQJ6{$^OEZHQ(ycbV(6ZKh4 zGA{ucC=gm~E2(fQN9Fj@UY5--8h*eS4fxDIC8#rVap+i=C75faSr#(g^z%}i`HTfj z0M1dWqV7FT<;Yh(Bt6NS*k^Foz)<>3jO4Z>PC|gcBu-vBIwy%>ntqVCC536+3SBuF z%_Dt&3aRNI?*qo8v`X?RTnyBeU4tZi!XfJP#c$hKv*fJSu;a#(Em2VQzoG|D^oJJy zy)3;F__7#%mYL>Y9(U=ZDkv`{(Loyy##(@_)ik@JhLuClwUC>+80J)i*^QlFj3dbrPny*}|H8Kt;H0a{& zHg-W6tbgzqd3Xup@ycHAD>B5HA-QGynpX5*r2Hpl7w=pAms@G1XDp&YsB?a`0c%&8 zepLljy(neAgCMp=b{B89B>u3&xL(-3unqUMj-=qeIU0CQq7N^O$RIexn_xU^3fY5_ z#=$1jkyp`}6j_!o`k&nplJbg!$lzsd5Ja5z%`fM1!AS4_2xUMdlT-05KX2lU#GcDw zO2MtiY_fOVrx#5ova=KVn`(TiiWz)Pqg^XetKpAPSBSX9^E=?+<(@A(_)!eWtg{so67-I17H4p}USgzeW(K$`1QL~0RN}4+57#^Gf z9c!V&EAY>l=2in#38`L)yF7jBHwC6HT{oaUx9}guBl_J!n=}2$qJky9le~_;Mhd6Y z6w5&!9|w+x_h)mokG4d}d@6c4ll|eFG}zxHE(!ww1h+XUM)KC*o+AMTiODwt!8ek9 z$lnHBroBO>@~OVpt2S6uy!pl9gtU$)CHS1#n*g*ndB*mO?kke9bgC*NZ|}m9c2de{ zks;-m=w$_z&np z({VtpLt}vhL5?<{Pzr3-9NSWOF@qkY@P6$T(p8j$Wgj6Rurc)}{w!iC3r^J5TAy+c zy{*}T+?=D+<{?pVWIQP%CB!3#>fV&$f}uI`!UtW8+}=<;C;7`$x0!Hy)6j)xsyHdN z+D&4IS(vk3l*l}xi}BHeW&!Dws>%=SN>SL@X16m5ll+@p_&wFdxG=)YKNQ5|+bz(E z^Aw!vtx>Hq$c?AWBip-;w~s4~G08J?@~OC+clc$=VWdJR1~XRvp>AVcvrho;Ff?ww zQ;WwpXhiE!0M|||E~J*_)NbGRn1`adU>Y+NZ;XLC3@N*-|2OpN=vR|gARf!QhY|`J zzOZ{ptx4FLE%isQ=uTV^zb7SNMNZ~~Mhs9=CP)p?uN8>mG#%O&F!&m4e5DaU>f`^d zWP0gt>WcLgnqr0rd2p{|I{*TZ{O$(dx)E@WhXGA0$eKi!bnU_m?n~&>*vOb)F6WM1 zMT3zGf09R)o!^LheunL4vcn|sT>AQb02nfu1nHD?mqeLgdIiPzSmY7JFGu{|9zUKc zG<^nrm}bQrO{Y(msAYbhbknm zQT^*z5hVFzz^NhqE#UKP``|AZ`VI{roA)3r`+_*bs;2SJ%2jk$5!W>#jAT zHpWVnfZmp=)50&Ez-^T#*+gmJj%y{c$QzF+&uUg+lI)Gz=IQb*EP>;ZHH6Q$pGtz* zwl+-pk8M2rTnb*VQ9;pUNsqooziI!D%Qh!2SO`?f_|eDCa8WN9j_xU+2|2?i)cp)6 z(ZIk6K&2{fVk-;rD0{uoPHc8y${JC#pc}vfnF?A&5@Qa8cqC_qg85z|T`DVH<-%WU zjCB^$<|^O_E8?t)C4Rje+f1sf<@R@f{WB=VchwGY>8GXDzeoXLkJsH8D~hK;k&hs2 zgOEE3Oad4-JAS0YkYV;2NSJiCc(~r|KAneu9-n{*eFG_<)PDm65iIXk7)>>pYFBGdu9!^w@qCOHRD^idqwFUos)=|90Pc)9 z4HOEJyvbgK&>OHE_~H;_RQh4MGxNksCV|DV9tpXvvw9ZjwqAj%1N)gg1@%6i*t*&7 zWz|JQYjG6wW49$AlkJ=%j}wYaFDheknr&Q9@ixgngv4>!;34Otu1Vv+Y(tTz=X=_R zwcAJ_EzW$P`}cJe*II?RiJ(-Ikv2byJ{xV08QVz+GM)95YE(ib!{`1Sg-0Lgt3_hj z6~)8{pia}fhUNI)nK1z6lJdl4K=?^*uBj};WL@GmeZ9cu^L~bhO5zpQDgsQw*qZEq zcx-iAj9{Ie%;Ym_52*C6Xcixbiv+{%1On3ibr!)HiT&`X$kc~ZP2AYHFK}-4GIlzu zw?oI0;HbUkE+1wBMkXo!YwcqWb%9~_75kPQhSS*-ehikROkQJ!T924)RFc&>>}BPo zp|%ZtZTz(`!vR~RWGGr0@rDzQPI$?L*{$bEw_fQFRc|icSkjH-EtJKcLF=diz;tD| z-QCkPvQ)tr&R1ZyNJ$Pm9*4FDUc-Vli^AcFzmVrJY@!PgCmsZ6T1uk4_9)ZD=V+Ed z94sDMNg)1lBUkVi#a}5|h5rqhBO~y?_kE_gX?h{3@`r`Rvsd^~zu4L;p zgsA5J3Tu?+!WR(ExzmY4{3u6J6c zEH*xHn^Y(UsgWSK;_n1#N9Rxitx^1~O3qp2pfg1eP0kfG?ltG%DszkFLq&S13Y8cV zMgx5=^Qln`q8CS7KcP{fg_(ujLL&$_FvT=tAAZ<37+G*ipqi{N!M_2pwaE(v$mvL6 z=<}w$a=ISFn?2m;Ax)9P20qILVG7v(;I2tYhn}%yAL7AhrhONAyoAy}+Z`wBl4FIG z!zrcs9N-6ou3Fnq$~_=CH=mX#tFWe6wbAkD^YruL66jvaR9ttB&RE9OJMReO>Oq~& z)Hoq2ApH0H4*BivM({|G_!Oi4cjVbMqx$9Zq|jaFtH9H^X<$lH8{4%7Wy$@0{})~9 zLHPz2R-+GH9St!M{xG>ZS~PDGGaN>1L0D5+L{CN@UQa1bjPdexZ#~V!vL1gR)Or?bpE*1 zg1K<6(_V~TMBRmIy>oKw0e zr>a5;*RpAS_HCz9xPdY;232z1{f@(yfisNG9*O$>w(s>-(P@06Gc-45_Q)KB{6rMFOwXjWu^!>dN0_f^VojRk7Dnq>!gX?)(#&y zQl>HQt@dnVf|ervAb@hqt-%Y4ynIXEaWmPk3iEPZpZdw_GoLt2al9-caB3=fY=sxsOXELr>&oWeBu8Uq+DL@O0;9 z)pM)Di8f(+ZH?l%s)?5P9Q|$dOpU^=y>zAN%r@ zVt;kDt{=EVW&CJX<+uJ=k)WT0C?NU{d+@7svS@DT=5ETJ;MMa zbLt-%sG6_GIqvGZGY<@sc}EM%a!BtN4h}QWRb3x?2W~kpR%)QHTBKYxVc!Za1c=i$ zIKNo$QL34vi;GxUY1aHzI?1_R)$^TJQ!FjRQjn(GXrSuy{@XiTu&eQM+Y`q*_zWOn zPqogY2im2Nr%DeZg|HgW{SUe@XN?{dx!x7G4(dPXO|I357v)sDzW!yZ8dxwNw|&-U z@X53$fESQ#oINf?OAc=Elwv?pe~}E-Yz-m!QTC8*esklkX1{3?E2m#O*MvIBJ2<|7 ze^L*KzRhiBgneS%9wVJBfYifvM~1+YkU~i-{a|r+M3p)#(<@MnOOL2mJ6uc6N`4`^ zkm>6h^B*jjB3!DGh)>fD)E@Nk^uIlB_XG}qrXrb9e9b6AYodjG_3S-LT1$;byvuAT zg?i8~zoYVGAV9>vw`Kg5f!F6Iw`D2%TjuxjSGB6?(O%_$jDEg7BAdF=gakLs1|ydb zK%yL%ew5K3%3mnhujdIhvF!AK(cP>`(vqiVGQgQJw{5OC zIpWsJr%n>%S9b3Egd}o&7}4GG)=Z9F68tDV8h74;!#lvCM97(!+Ef<6wk^)6MDuU` z`QhqvIKJ03$XM^KH|)Fx(^J=Sk|+A~bKgAsXQr^4bOGrLSL!n_5$P3^@N<-0bt7+A z-cfL;W8363-9{7CTNk_vwnwqOo3KFA&rmz!lP2}_6j+umB%ISf>q|9sJtT!hAE57t~`Vz&;OJ~lK8@vN3=X; zU#S$RDNeVzhYfCGwY<#Hx(3#R61NPHl3Qr6RN%^3-L3Gxt{%zQg;FM3A6g1iUPXK( zL=sVXVf&$`d(E+$J+MlN7hLV#h#YFokZe8i2j+l*Sr=2U+P=!pB2wl3Tlow9RZR!> z`bDH&VXuV~t=GBS!OC7^1rzn}MLCzv%UoeoypUgO)U`i1&lkImiz6yz@oi6tFUV#( zee{%uk?>e*l}vB&mePc^m5?AXT}!vE zaVJ1<0>RzgHMqOGySqamIKkcB-QC^YJ;B}m&VA3_|I0n6ohMr3Y0WiPRgD^>_x>%b z(12At7?geO!7V1;;z2BjL!P$60OihlPQwgMq=LL=8xm5Pd=dNPJE9|4rUWk(ucq=4}E?Dc-}N}Cpc z=(j&FK~(oh>Bntma^I#_-3!^&wu-ZG43nOevbiQdD8sI4%FvX_`mT%8ZR>v=0Tf5P z_3MM5g+&yY%or4E;{z4rrF9xKgEJtvJ`0WAq3~7z>rq z{7HazV6zRKPH`R87#rVck+cLPCtUVPkl}A%o1^@7Xn#c3Pda-oyz{E&z>iO84Y>8{ z>s4V@Yi<5RBH+lmjDB|B@lbu|vb+R+WHfHQU*nfZQGf{X;0A#(S79}4g}s8-(MI#7 z+?dJTkKPn<%uf9@-`HO>OJvRLD-O|V$oqZR8;4W=D?-eOIVN(Cwi6fQ3|0Ll^knnj zgtS3*;D3BO`A--2LQDiSE>}D|^xu;HrPD;CA(k)!0BDL$&6y^Z zD5w*v+vCaA&Y}S}tulh#JrFXj#-obuOzT4_8Mta5HF;u;xtr~7=!1&ypD)#VTIQ}7 zt!CGblZPS*E_jqFvsKC6C09{pjhi63<@Cuzs zSG##Bg$~Jqj8cMah!%%_k&f?MQi_0O?+pymLcZYHpzuyV_x^<56TQw2BdvG{{2oG_ z?#Uxe<7{amgMBqRwVp(q&V4@Xjm9sZ4p@W-C2t=LVF}dR#DzR(n(gSvKk))BTzFm< z5%>ea_ccb$xeb+_w{n2+&An(Uys+EvZoYpav6-Js@&sk_;s%t}1^eI*597HglfTB8?kaU% zv;Pkt^xubg_zuP@9a$7;fbgzOp5=@w8KKhP3f_CNVf5cSoWq3n1Bs z$oGmeqvhW`G{=M8XTV_&vu0-`qAgY2N8O zZPXxfqfC0ze9@%yj$*a$Fp1%Uhy2oGv+bs2S_B4d*ZV_z=FS1KB42T|hs{Qdfx)-x zxq&^2|Lr5wLOh@_h7%p&rD6%Z<>(P1j$^{3SRTp~(t0)j-IRQ0s;$Xzy#*iySwKj` zgO1y#-^1!pJe$jrS4=(JzgLfZKAw19)^1wqYYVB|hXB%`(sI}fK(VQ(M-j2alVsVS zIO`I*$x7YKSAu75I10PqqGAA`vR|utEbHhEt~Rw+210UNrS$Z(zVEUrOeSl$(@3%H zkoQ24alLw%(Xi|JdNXMUzGjlvDF6rgWtzw&w(NSo=4c`#4CqJpZ2q_BLxHe-9|z!I z-zCgL!U%8=r*!noWkNdE#aU=+r~oHLbUNT*QxVslp~(Y z%v*tk67FP>*ZD#z_?PCGr&DA1`$hJ2Hk+HoYVD<_a};CUMoU&dMS$ALTV*nip*{gM~HLWJkn=oOH$O^4mtPHt#9^Cg}#D5pm|F=2lXA{O`#&j(^2|MQr z>-o0s-FXa{hkW@*E>C01pb!8sQ<#i~&VXr$=NKh(U)g|P(;IqNOMbgd1zs!KQB}qP(*6LYSk_Lf(Q>uZ3-cn|eH6gS>`=q^ z;YO0{wAeGUt01A&Ic9J=HHBc!0q@fJe9Z)a>3A#9t|X`7yUk)VZ~gi3n3EWOrA}FV z>;rU%TV)=RMZuu2Ze!?^Q} zQpsODE)@vb?Y3AP_U?eVP|i2JT#NcC-Oe_4+YR%O3iGi)F+aWx1-+Q7>MRbu;gA5in*q2vR7~&Gm7}2d=1mXEjB(`;ghpT}89n001PmWUWX@+32Nx5`wUNGK(NzFnKG6;&o9tytJ4y(7xCzK;r1K+0ak_~boNBKgRK8^jj#KGzDo89Cja-J zx(ng?5&hC4dvWMkbzR)M$mRe2TF<t%_L=#h zvcrq4WFLId0P2$e`l6Iv<64-2QF0axx&_dkuq%KQdzwkJ z&iGi09+6o0_AmM?8s<9?rp#?PV4f6d6B?m=-nkBJEr52zr>^O`cFX0r@jzL}=Z6>& z3T>lBvK*>$s@~Cl=^KO8HGf;CkymRp46yCNX*C;ayj)oGg==tx^{@9x0c%b+04}uo z${5CIyAjlW(Y76Ac)FzJHn{3@z2#-M3&6z!fFgG*bHV-s@VFWFXR!P#Wug3$1C8(P zJ(02u#N&Fp9U-K>C3o}8RBfV!;5E4{-$>ST@8D|&=W}QBfAXfN)nm>%9J5o*!IIx? zB|k}La_vWRUtAY*+>C^SbC!R4J#MacxfSJbxmK*SSlGQ?bUx-X8g21Btk@16dJ(^u z&3V6`yIr&%3Wj8=zEoCFEoFIrJnc_^!QqPtLjcp+YRAe9h(ivrha1_cCP(%wAOeD>?bu!#jZB%Yu8SvU$S1m$985KBj&&nm&6zW~MP1;GALzyJ<3 zz=nj0o13&j@|<3w)jSnF1mJuDZgEzcc0dxH4q4=BTi6H%1Q9Uk&^|!ko{u)kpn@BYCjUYexdZ2o<(dd z6IWGcU-z7|>qBmqWa4q7D_frCl9kJ4ms*#b1Xf6Nm3Au}6F2p`Y%u#YquYfS*&Gw} z74S30;5wjsy~>pA_SPpatpaL3vuUF`{jP=Eznx}$#9lYotyh0MPf8De^LMeg#3@@s zHy@cpK(Cm}NuRKo&*&rhE88TyqDXAkE`8ko|}>Q5S6zy*NY8@ zM`qUb0Ashlf!z@3;kH+MQMmuR?7jDpOBfAV2s1_Xr=sy$O0silsr!n3?2yL!91?LM zzd$_rK_I+;-XwPed@a4nP=zmOZ4vnCf0tXR=zd42zw|yl#pAy4&|B_EC|ri1cJZf0Y*Lbg zvshXyrSM{4fXsnX*`>)R{2@xCUK8WR$EnmM;WQ?^`g2c2vHiDyTG`B6#_!v$lHLSU zzGR*F4Ctfe?l+G+?V%a$b)MUa2=kmsti(Dn6F7~@NI=6itSyzz*I5_p1s-AvcUubw zwLVV&8QC_zVRt_|&C3pPqk@tjOjQz~Yn8lcolLCSU38>Uq`COJv7T_--3T|?(ka8` z)K-Vq0MYB2&)ck?0`&vnq_I^hJ{8&NaLCyt;B=T6E-p~ZCaCGXo7B^!x~nd4dR_KU zk&J{Y)S;hLLM61O)#SKUR@U-dtZ#RfIqVNvjDuMpKV8QIF}k0P)jCx)u1}?=mL`F~ z=5$ciP9+*97?~tUsSEjE^?-7)03vF>1dVZtUBT>VS9WJLT(bLjV6oy;L%4_3x-eH6 zRF4h$4b0jD>D{28G}QPela2K(!=h0*Ch)t613=tS#4{*xRaz@8{<5;mBynNqfz&cL z@R&@V+WSz8zT&&&a{!pX?_3H7ZRG7etV* zw99S&h2&tULisWL{WTUzKUW?-WqD-o3>8rKRX0tAHmoxACF(K@w;CJ`7RNVqHAfTfMtBNN8}Y5rB?+4hskuexQ=DXFLXc4`2qN1AM2XZfs>a=u_Q;V!Opf5JW!euSf! zi$=N+Dso5mjKms|Nv9Zd8Z@Cr@1yVj=`x=+JTnIBg776{4=Xe3h;6*9rBbC~P;gm& zOp`b@Pe#`>2s&}eSs=txv8h$7DTWrmt7M$1j z!uet1c7f|oZDJG;0^~F|aAJPKz|XsDsSBhbS(pAj7*+Z^trwcl`Eo?Jp{Zh}dvmr% z29ht<-R1a4@;k+3x|(%Mn2#%0hGK-fhqJ+L$y@EieZCh6&{ z%^SP=gz`4^CTmXjbEp=Jc?>V&?{3<^rJx|YAqA0J_}J7 zsm<|yW<5}K`4Sj|%kfekfEQRnZAH*)4r!^~<}Q9Pf>G{if-<7fW#;wUB2wU@)5xK=50rmmD4~wFIoj-&qOz&KniqdEnmfFW>lTC)F-=D-~7Mt`xg;&760*m#871t0rFucex6tfu2WCyEY+Aw9p z;_WQPRCs46!qzG@;(_-p`K#8Mrn~^8Y(JD(bF|I;hoTTzyne=v9N%*RQ_y}WiqXeI zGmbXdbl+bs2?9@#A;CIOlC(^< zOJ6fYxI*$T$9DO0E!s*^cF8Ef`DX3~9+w7HR-Cc3!8GfEP(oup_8BOSYKxhNYxAQ| z?DxWa7|D*&-I%3p8!=x>!|yXNwe4Mt$|)n+IJ}=Iq@sKFE`fT3)$1X{WL%Xa;{?#5 znXlCB0>^!klw82ApGx+5N2A6bVdihHvi$etdPL-)yXX8Xpvi$*F(c(X-bKGgDc~Kc z97LzEKG1I|rjbn7W)~z=Qb>T;3-_*4Ylge1(wYF#^f1o18^kBFn2ee77eu&NhQz1q zUBCzKb`z$wfX%JSrkY*rP9Q69Q**9Zj8-eUl8B|p`_5apYTLss&Q9iXi z60a`msnp?t%0#C9pyW|WZ~;qR!>o#(S@k+d(W3H!xC@}n)lZR&up?Yis>wJOqu-yh z*Iddd3TY5uN(V;c^T*PaLYA0@Md+&MS6XGB-PRjmc?>4L0jdZkNBi>dSt^J^Rk;z7M6bcKxpO ze7sNzGUF3eDFRpS1}N`}ZcuC=a02IzD0VK|qUOLYjD?d`@uoZ!(7>#&9FCG_FS5TR zkl?Ggzt3tE}H6in8Cx}yJe z`zS*$N$pQAizCZ|1`7azKu~#{+?C>xKjfs*Zhf0yCa3ISA^tGV!1NrH-x^8zvk&XU z$Si{`#J=g=?sDx~8yB0)ydPpciaS;}rAL4X5S|!a;j4lyg0!v&1o-ix3Jr~?l?g;Y zIKQpL)%w26Gtk1m0at&V%=*9^gSq>I(fN;;WibaWO33EsElyMc`ipfJCmrJv}FbiCb&A0!v7lz<4&Q=kb&`Q&{C*uk9l zfjlOO9({M7z;Fh})Q*oJ-t1@wg(!|WtWq9mKx3k!)$X-2CtI&V;d}qd5d^;p60(Tp zB|OCcu!qhM?m_|o1j|dJ)uIS(o}eP1D-q-2SPILhTQn@K^NS0X?f?W?A6>8C6_CGm z)Wt{$Edc08#xR8dkyIejtg2(Ci^3O|akAIf&_h_+hsIz>pEM;jIFf94l9?)AF~H$^ zZ_qMG{6|Q%v9+3|Wt?P$0Ld;+im_&R(H+p7QiO_!2$!I8@!a-7(vq88QKC1eq2|(P zYT&K?`VVrZebz=@$eJtzunoePsHS5{sMhKy3>*u{8zM2oH>%d-bb8{&m#|Fk0@zE? z(klcb1WMYImD(SLOEl+g_eocGXb6t7T& z^}tyK!?2n4SQojsMGzgVegeqeY_2hN5@^I+NRbIFP~nyR-~2vv5WA%HFN32irZ9<0>7G!Pzhb~nXYO<)1D+jELz#IA zGnK7Q7Ag?hBo~PlONdTnw0?5d!saN=g#+;`xp4C3Si6dFNU4R2aMIh`9V)~RLXZi* zzsZagb$y@FIW23jxa8o{Iu<#l2DEHjmFBHx^B{fAfb@|n#+o%d1;)*}S|CaIA(i=% z0y9`_OkC+RhKoon$)GWTF#?_uX|t$|kQ~>$S2}A}0H>-3hnwkW+V7)0t3##yZ_bPf zWvOqA?5@8H*tYj=CeQ>;ETu?Mm7B?zs8%lgQ=4R%f?-oFrSEH@mA$5Abp2Nbp^}&F|NYlnT z$b!Vf2jmtg<$cAW5m_8UZ7PyJ1Nq4Z)d$S!8#NLkSsEjQ&_#uzBPhMb`vmQUi6W~- zEz;*+3aW`l{PoD7I>9Qu@0nSs3SDm}n@^bm7sg+1bFfimA~5B7*&B06par1QgNer+ z+;CGEqeEpUh|XT7hZlM^)!2SJWe{p+DcDa~Pz7aF{t~nt%@Z4|86PZq&MCbxvTpZ# zy~cp|2u)CFZeL|AO0i(w_BBE`VM$`ZEa9MJVqm14J|B$e`Q$kNZJrKo=0LbqlPaNY zslUWcC;t1iSO5=zXl~gj^3M^Q>y?x_?ya&k=L>BBqqO>EIjJu?Ozoxw)2!z0ntU?U zI0?*e${jcgKEy;D)MkO8Z+VwoG(I8EqDAWnCmPg`R|EAHq9rak8{_r072ddL<&2rf%-n@fVs{*&2WejQ$ z3aH{Cwri=YJ8(;lQ>>)lmb>|eD7Y>=m7bKLeyW)M&vff3(!+)j;s`vzfvqT{++Xl)(FV5*MNL6tHr&3y+t9%b{;dFjK9 zLUGx}4PX5TpcUfSsQZ=zZC-yX7<6nEPdj|C`93O9BRyr4OZ7o7**k+DKgdm4AATDU zQH4q~irhz4XNUOiclFZ>@fw>4sNs8=3~U}Q{e%>RSwP_teN0ao0}`VJ10#2+2^o+m z*}l0~cRo5Rvr z{C1-~;e(=CuP*`&y;vLd%e*3#8`-9lF2$5j*|RI zw?I%S^nzk52*xz+uNgdmo$M8nM9506@nop#JRFBit_&%4+C-=2&c;K~mel{v+A+He=soU;T6z2h%s+2JdncfPJ> zT@UZ~wy>z?2MaD$jWq}4Lsz2ppGobJ)g?&LU)^}psyr-K`x1^;3!xl&Ly^MpCQgN| zA4DBP=mgXqy&z{wp8{Ut@i!Jw!1}0dO;#FSBe{Bda0q0>xt~~$iYV|BANx@$@3SyU_qqD8f(|_az3$z&fzzr>z1c+f$ zQ(%Pk$9r0={vbF#XMNUhE8tMm^Whwpp2SDI?g9UOD#zv8Ax$(QQxRo>@Op79?m!I? zZ$56bFPBTYuY#o_9A_^93apWS>qT4j70;g(HsE$!1I!8mveXo&dh6RwZO`E2P zv){gvpmn;Gcf9BbAGRLao|AHLmRV(HWg(k~brj9L*z}c+zHjDHbM_ z*4B>z0$4cRs)Z+jigOEz%lpF0s$NpGm{0(qCV|LeRv_*tj}{}EqJ5P(n~D+!9JxQT zeu`_6hHOx3KFFgsposLN`}2{SSq?zPFE3;zrR^nwv=D4CZb&UA5P^5And->oP)qeTYQ<5B96&w z(km9~LUWeTc}i+6tRg#+hArWCq+-HBQ>&WP^*UdUEz^4*GXZ-&%kp{9zYT_M=qPc( z`?m|8Af|uHQRkiG{tfsK^mnkKJ(`nwS?*L6bB4(zeQiRBDCcrLbt4mH%=F&3j#vt- zn0X8%{k;JRs5(l#eVs3o;7}PsBl~QI`m`i+j1S;bjKRB?Y&L0&aypR(5ssKy))pzJ zMC}-nkb^UQ#5I9$MyB78_rcUDw{9>7+|W<~s-Bpa*v1>bK8-guQl#)`|xoO7tu7TX{)s$~gR*(En9Mw>W zGRp-#DJJk5rF!l#Xx>_}s)8IM#&=MNQ7(LKNuNsPGJW6gx7W5NsyNUsCsNq)z0Ov9 zf69q0!qV5g^p))d^53Sy$CfD$8EShTKX*DZBVx;72;6M>MjTXQq(>R_dtfnW z0sms_+{T|iV1`93a6;l9tn4PaQN0}|&e$q&c{dNuw2!SHsS66nC-#l+-6Db!DH+Bp zNx|mE9~IZ6iiO@>eYYH)q*p$i#7x#nALTo3U|c_)#8{==Z{b z#ZX5J3WNnTktJ&(b7qUi4Z24?NJs`aAsX&+Eec|TD`igx!=GQ9V7k3{78NH%c{YYBHSp= zNh%Z%{pCzdxgL|U?ThMd?x=Yw32Xwv&uJoiKKSJ^m7`uh5swjRsl^e1Q=KDMB6%L6 zXkRXR*PQ*bfITMSl#rZNZi^XC0P?&z=+@;Vb8{%fpZe7we44(R#qw(zM^xrV!QG#K zhNtgexqdUiPR~`;lUwox&mvP1&{L>9u(i)fWLljZu^Av z{hUJGChN>D8bhLk%wYhAz`cskFXFw>XzwzUiXo<%H*1YnXFNFtiaFtPm_tnnsR(J6 zlFIgZWhU9*c)Q`ozz*nla-LDu%TcfZR;tghJ|f0>we{t5G5$8$aT&05L50LF7{0r? z-H0+GzW3u>UTC-g)+=~=9x=sdZOi4Ve4D(7gg;h#uV95^-IIDsgi0tsp)x13F`jj2 z<}|ZE-v$BuR^w8xux}eBLAhZcCYZAKK1p8S+nkG}68OPx)&f>-6@4a9+`HT?y*T5+ zH1;kRj};QS3&gWi`ZLg7Ew6jnmHe9pK$ac1M+%`hc3i!&d!|`L$5fdE)$pav(+ks+ zr;~TpbaHL&>+$Vl_ovNkYQfQ3aO^K#b{CVc$}Bodt7h3>pzS!Na3G(RQ_n`>W2y!> zTO3e><|{?TcnkC^_QE__LWI9&I$3B36Qvx&j#fnGSrK%NQ^EV6s2%<#1d&1o^)H<{ zt^WMw(G}D^wX{%V%k1-^h<1ydHP51a=^q}&t(lUt(NOxNRPLn!VZ+9W)4!QBcenR0c#=|d2}Ivls|5U0F^)S+mlC}f_)Gw7W!PAbVcVLgblFgx5A1A>&QF(EiqT&)EHmQT8-{?sK z9O^6sY$E;j?@d4{7^Wf$lX)rIR>)F5Z-f4ZyeXxjHT6sF?m{IMumw(Ka#!nia-D7A zG7k5Yi-1tX=8`1;lph^^X6yvZ$I0OTbZOjA{3W1p?S_-7sdNPiPleScT&@5TkE-oM zh@?AhL1_gjD%xz;tZ6G)m#w>vZA@FGhPxc8M06?8UGr5^R}@hZGJYfTYK*)EJGWtQ zNb+!q`cC4Y_v%=24hecm&}rugXdN1rQYylx+R4EdmKmX$1^wD=U&z?O<+&-o zetf#qE-HT-Uq zmbg@Fxrl?x(B)=mVo8Z0#E@OlO0=S=k94l^vvAA}{yP;V`4|mc;PeGG`_>b=)(aI4 z91zN}o(E4=IX05PTin1US}BTIb|LTnqT+j(lV9Ei53y8F8ele^MQ^`UC?52(J5slx zJ%b_D=Yjp2b1TOJ8snGV{p)9srrSsE%}MW_;~VKDpC#T4LTB@|QWxC-?VOWDEUgq= zY7tQo%G|U;sHE8#bMcVnE;x7A{`lOH5O(~qNs{>SAX{Y#mbmc=3A~{ZNc|P;uCx%d zfQ7Cw_u7(|T4ZKVdY-t(INRB^gf~F6fX6Z zX%byY^(}Nwy)8QAJA|xLViL^x8YnlE1Vlar#+6~uxbyZWl3LzlyO}~%I%ys<&1;Y< zC`_-4X0VP)>@d0R*jc)zC^4o3*KDcv;^>&G{bxeMe}X9*h^!U zL8%P5X52J>h?ka6()6C+M5O;KK-Cw6@xCM6Rv2-||EI!30~<;hs`IFqzeX@C*AwqI zheDD=%!rP4#)yqI@)9)Gc__FG5Agh>oTcW2=p7HYa@dCjr` z0!FLRlbQ^F`OAVogF`%&mC%00lP;<&i0Zey+MJP+Te0YQN_{;%s5v|zHVa7CO2GZZ zq${Zk28F~}Unf3ShHbz$)KGws#i@Wb71=>zU{~v|{i|p+HgTe=s^Uo7T~Q4s z@qHIvv-0o)qa_t`!fN0Vh)9#(Jr4=id@6x;51qSb$DU3B(;9z{D6rjZ>zkO$Fjm^j z@cBEHY39W$BY;=?kXlj3WJDgTVJ&g$9TuDS1nKi-Ccq%WsK)LE9a~6fy1z594 zl-WcIARqxp?n;`pq?u52B6R-Nd?aByxd&M>FP0-8i9t&xZY=5&3-UamGY^4YaK`KP zAV5l>!g*21P(zC4OYW3$ml3Bvo{Hc_1A-Ot(iUi@J$SMs>1RreYz$dS6QDQcf7o(& z5I>zJcD#71e#@2BQ$N?D!b>w&P8D21<@wM-k!Q?&B>&yhBBC^60ctvBq@4x@)541{ zL-}KuPTQ=txW#hJ(@gvAYB%3nJ$4OcnvW&sA((?1G^V(4;Kt?3@>SQw%Vh(ywk#B^ zvm%w8B>t_6wGe2yD}>~v9&Pz7Oo*d50xtCFxM{_H+PQurR_3UL-(V2DyP+u6N8#t( zXYg}CX9-9(axGkkOxdK|GbqL7xmOFDV>&FrlfWN7VK%G!vKrMaNjeuG#rDZMVJwSD z@>pBX7~Ik@V!QtmGuGB#c^yPwX7v-4+1uV}?JHagh-AWYXCCft^LqKU>v@fQPxore zd^2&xUYpIjs_Cyfa*;mc>2jx7yU2AZ#~AEb=y+-{1Y}O||e8Zby?O z2=OP}1V8m=PJVx%_rMpfCW#X}&PP_!zSb*hd!Vgd4hc0)heAA=rd?oF=THG+S_buL zA}W0d4cP6f)DKa*|FgK+QD6+6*+CFg!lfeXyAFywb^>;VYSaU zder1fj{OC8*q>aK#Jh{855Z%*;cm#l5fz45RNvh0*Sj+Gb43yk3-26Kae z4tEgykU6_`q_0yM!%U+Ft6ra>K|g*xjMY|lc~>qZZoh%l!2k`qbAqB6x zwcklvb_lK1{$r10F00L>YgXYT&JZtGQ>(C?_O-ruIb~B{P2w#*=`;KUXys|7re@QT zf?1rmI=(6@BBNR4q?{^ZM|0Wym9iY%2ZCudp8_v=8$V$Oh{b*J53DOb5qdMJ3DDpKSzy zXSJP1ms`Uh)1Pm!0ePB-qOQQ0ZE7H z>5tI^P`V$q3~Q?gO>g^nMOBZ=YVsM^7n`?V@`E(Bx6&KSH1o?XEDOqbm>J695e zsXGzI{AGyNfzw>oE>{cJaUw=S?{m7=SnsEea^7Qg3eJqNu?T4BHxyNr6D_Qwr9leF zZCDKQ^8!q#5CUGK?7RnqG~kTd2Cel@2q>?BamMW}z6RH7u`Gqxatdn_H?`2QR07Wjra;)-uR?{UiY^ES0WnfeGij=K3{BE`d7h_SHtD>+{clS z*fpRJOoe}bXs%|liPufZVM4uC$jNL~410m=4+B!=PWSHIQ&eva$e4h*bF1*dhL}oz zH?q-8c&jf6jAJUROKtu#v6Nd65maUAWI8IWTs__pmJ+cIdcl+Y+q;d^+Sz=(F2}f< zz<|v{^%a=q*V!~9y55~=&hPXV+t*Akl$KRlhhU5N|GOe{&;qD91LtzCNq{Q=ATw%l zc3>WT^{Ztpl1?2Xk{M(v5%H|kjF-~%41M2qkK6SR-Q^8WF@MU^;dm`hH=r38|$u!A<^v2SI>aM zA!#2sx)gw`HItW`(q>$9UY`Ik1j6eU^oL%D8+8Vo=HNPd`3UC@nilgHZ`0|lHbrTGs0U;ZKXsJ zL7oZYp4zw#qG9imvotA=FAIITez5zTb_58~s1{cvOKaqx^nj5zwNZoNd~7d`aHMKp z1O>cLYUHC8C-8=AC2ETQc0!7hBt)A_)m$F^m91#o3m#+781*?qJn(!X_404Md;^ne z2SmHeX&xcReefXxhL+$RLqnQh)km(mfiIuQGO*8jHr6-)DKDj99aoqKyRum3n`OQEA3Q;c)(4G;pean|6}gVY}mz;xD@z zxXU3X3}F1thCg;#I1{awSJgQ1G36V$J~yvjv^qVb7NN=nm=}7}zK*LaG#*POFUlTH zokPmL<`o#b$gt(xdn>gS7Ou5i;*qOy}m2+g@0BRxWijp2a@uQ~5LmU3CT>Z`W%Ttbny%Rs3L_CjknpD$ygwhaUXoSN zZ0oV)8>Im+YZ{)*M6l?Rg^STmb_M}XNeEJkVxSnrF?Ve9pkt-$%^9D>0dVyxtCd5B zDGDr(<6A=_|Jo5^#0WxXd2oPUsdpf~=2@}LQ!Nd}ZKvnSc6=I!r#rPc_vuD*`h6A?hqqu+Lu>)UC5 z;m!2p0_(%-+K=H8I;spQVvTiptTD6kl3Q#-la*AC7ULaS?H2Q=D@N-$<6}xpa_!TP z6)ZzaCNdb%kl*5AHK8e82nfy64&PvI0)R~YNUgk0w!i5e#Kz^p#?8m0DMuLzVsSp}n^aju1n)Wq z{nl>*FQ%flSd*)q3!R{XSW06(4d=QcUq&=zsQ>lD<3%&wuCe#H}T#O9RR){@jq%h{B>t z4`hxo7ezKY)34KLKY_8nEJnp+<+HfpbiA#9KQTJ%1ojx}fcaWm>@ChVsa%h^a@@Q? z^IxczWXHI@=EM|CUhpIFbPGpG&R2Nc^5ezQqP2Rn-$Ces;C4iK-27#GH1@NQV1Z_x zbLd!kdCA-k@D!C~tml@Zr}M?deA@3^vye5u<{XyHKq|4~pge9nV#ifq4QDWTjzDgW zy0wqOoJvQN2JU7sh9F4XQQLTVls?!FH=xm}J+Q7?Ez?Gd=VlYaE!I}&Vi2q&$WsUs zo;atejT;s|E{R%`;8pohVXdz1!MQwJby8dg4NBJg9(({8Wmlc4y5#u;$Uboev!3~0 z?`YI()of?C6Dq@$aEBaN+c^tawX*PuKg3inoIq5PXKR>AD&-JiK{|5z=Waz|KjBQ=vKqbj~F*8(aLJYb6M z{amsCA1fS4fVw2UB{Gb+5K51k&qIB-Xcra-*&_?v5Ku!fWH1TiCQMLHh$nzf$R89A zMw^_OERhxcv|9s5;<4@aj;88}SGUce`=`Q6fcXX$wP%-P`VrXMj~WHv?+?n&;W|2I zXiEc(iw>0Jc$25*SRu%PzI!%7#O#lPu#gIZZ{}M*k3alAZr`P2jmc3z0G&GBCVLd) z^UyaV#ID@DUZmQR&XhI**-Kee$e*LV%6i-M5Rv!hnH0`*nkq?dJ9J z^CODuUR81U`#N~x5v`Tro^R@^{mm#j;c-Q&m{S07E)1W!XQ`cB>28jyh^QUb; zeL3wOC1*$S#Za_tw6wWT9GcZKr6AUvT!0P)S9!TRb^(mgDwcI|_~Sp$fuYZO7z9SN zCPOoP<=rfH6LO=|Hjrg3!!~yx_iUGCpe9=YJ9+3#ym6*H<38+U+=Bn1teY0l%kvAMMHPXkvJi9ucn zNngPsOZZ8nz$@@|K+-h}i<}|>gVBPFG*%4W#Dri>5GHqHGZhJ3x8lsQiavxHv5PAW z!MWfjdZ4TKc%kLS?4R!FP)DYlHJVpj%3Bl)GYMpm^izJWcfUtw+5en-go~kcg-h~` ztdJLJZ69=8t53v;{RDoAjjX#(X=`Q8l;IPkiB%{DFFmgpUI##^Weyw8@yVhUax5U~ z{vH(V1JoSvf9M~9NKN)~w6OBMc-a%Dht=Z31!dIgP9XQ!;O=M{O&et*PEvaIh{d!CBQ(WAVzd#0c*dXj8pwnRjbp z3xH-6{lrzv{rtH6l;J-&%ykw)5uucH{g#EUM~yd_BS?<)jqQg2^ER1J@kP;x=?jcT z#zNiPOB}(V6expyR}EQ`@9Vmj%h}iDir^kJt>(X)=qu;F&LcAHo1#cQO~|?r-ncwZ z33I-p&YOdz$r*!;8QG<)Fg6>gS~Ivl&cQ+_jWE{4YV+WHP7rML=JE0cDsf^UJ|o~r z%HHM&c`p?I2+r4lp<&)DX{K~Wkye!Q;pKkSu)P9_Vqe)2xY><>miyav@ucDmZ^q3| zFLw30-oSorG1A6(}7`%D>FrV0c}7M!I<==m;`}bR9JH9HQQjh zcS>v_rPyYj?>+wqko7c~rtLJTMq!pa#`i)7r!CLv)ug--TjaTBQT$Z!v4`0$cN^hp z$K8JtZjz>0RYr>$5FZ9Qn#JptQ|V%YM$B8RnW86ezN|44NP_-N*G$eE!iU9$jKdJd z`*mFnR>|CdeG)?r&O#D`+-t*u*9Y|+YKPf!NnUD!{As9gbxCr6EYNU-I-W#_V!SX} zw?rWwZ+Ug8Ty5A@dhJW~)mgiIOwHHkNumwu)t3Rl1~d=&KrS_#SUgKpAo6;`^jUq| zkMf)r+mT|zFdB2*Bs)WNNnyd|>M31Y?`I{nN|A>;+%0WI;)egTUCRdv1oO(@Q2+@w z0y(BA3`@qiI5K3+zSVF>h2MmN!v26@OvTX@0y7gbD`ooDgTRDpI>WtzQWzXvsPlFF zFqnNSe@F)XNr-(cCYv>9_%wi|X6BhqpQ2#xX%$v(5{Ct0m0x%D9P#;_{Vmm=|7F&t z;{EM*?i7G^J5u-W>tDC1sSC+E-i4#*Xy~);0vBel`;*=*38Y}Rv+5Rc&?LgzdNmCi zOyixYes#m_gY04}wzy_cK5o`m1!29Puq~lxOH=cXn>+{_)&KQny)U)?qd&P|Z{7p!4qGz*y z#RH|#$jK?;M&Ut>CbgeD2VwfckySZkqw(1!y`7bH!t)T(Cz&!wJ!koi*JQsncJ=$+ z0%fYeE5Kk7RoH&**$i!Sk6jA+{ z68Ig#)XTn20A6`Y&Fq5IabH(UwcDofuVeA}g)b%`6u!hCH>rqq z-KE2MJY4aX^#H51LyCTY>9Y!F^jNjJxQ^?67iOkKz?PjBHkY%%^Vgsr7~Zo1B{0Hu z!b4pxjT@03c1Z(TuQM|&Gmy4aRZCQ@RXKwqt<>La;rrcu`ig~3$vja`E!~VX9t+Y& z=o3h!3=E&du1dQDQC7(BQ0A|qk#(?Rj*4xH7BTaOr<~#n3QVNr9gxOKZjpud! z7?C^FBnkUmgKscB$8RLC5XBomr)!r3Q1mDf-sYh=U(I8jYnS-psL5?63UzdjQ=)-!rfbE?u(!B{HP|hh}u2dsSdHPk=T~gGqQT zZj9@lO(+dzYc4b!G!EMJ(v*1RlR?E5puc`99T30FyLZE#NOK}Bd0zo)@WrMRe>$KS zyiGpCV0H_#Wy_MxmM_~A-!2^}etPBKx&!kagQlA6M*7PXCtK@SVKMvMW~y}}V^5x* zt0>2Ay^X+Fbwl;U@2-!txnfrb*?sv#thUSX{ll6n6cihZE2K+YxFejJw)w|W3IjXO zb=f#`Usp40BQ5`oV0iv>SE%c28qbT7S7H1A6!szzm0}11tRr~hO1hA-oSbSb@AFj6 zvmP@AUmhn*wQzG5&h3rMUAfF3`8IO-IJ=VskUOmVS=Qqz$)8xrzqb!!WBFnBa^3$V zo2j96A)Em8@gdoCz#{JTeMV+H_HIAgy{D?C5@jN(Ei~+ZuKPc}`akmRJc0bw`727| zqMUx6e!dWe5DsVT-`X@V&u&gEBG$BUK7!ZI!`oMW!r1$@tdZ*vSvj_!U|=D?_t z6yW#QX?MGgW<9Il@jCfRW)-UkqYoA4>1n$t#lel1G%L(24oW3(0A=!!}0S#m2$*#6+TcOBX0Qj&IRdIyy0D?ZOZx zI=hN3UKHKDBe1yl-RYyErl#ecuQEN(~^(eQOPGMSWm_h0jX+OYIRC5%z=1B!Ny$yFqZ@okw($@InUG4IV! z2QmDm8=&%zT`g5V2j0p=zLY6m1y`y@zR0KG|M1EyZIM>Xnxee#sj7m(UNqNeue3XN z#$@vS@^w>__k;UP2;VP^r=9n>$S+cPJdS|V_~8c+A|DXJ4yQKc2`g%;N8vr(@?3HM zZb>1X%vhFUj=p*Kcz4`!cXS{UhVDTd2mp?YZrcyw{8_1+3`L3hPw!>c6#9TTN&zRI zX=lZC1J@=MRC)0N$$O}zT7{1GVt(c}AUB<0EHoN_O9Vs<2VCpTlRu(DG%| zIkmVL@_xgy3X~+}wLJGh#WIey&!~WA(t>G~mQhII$wJ2$*USuPQdqu*JfK7?X>E4g z{T!u2TI^Z>?>ohwipMdwxaq~0oo`&MU8M0sIU(SSoU(*L3P+#i2wrxTK zr9Gb>e$7hACds7W?By_jy`Znkfkmgr=Da}Dj07lwHK4->jPS33<;)?Ruy!zuZ1(S{U3{xgDQLPuUO3m0U=5sv}F6+S*Xxz^6 z*?Tn0n_D8#`Sd%JP-#I0qsdXAuzcKa_?6I4T)E@nQgw&M(#xGO>!s!^L{4&IHAL^r zxg@YX0KeNO+vf=tN@(D5i;WE=J78zD&Q*8|5iA8`Gwl1aKoA4v@r&YhH@?kEqrIW? zhc|t3Y?kXhgO^7wHO(%^AAU{g@?MPgDJmH)?z9|_m@nU-q&eDS{immi>g*p#M$QgN z!C^FtN4rSpY5>>vo0TY;e?Z_Wg?U}gC^sRARQs*)-8m44*QR)P2f?dWcYu1hfuW7a z{g^^k(592iu&)KYK;FF~S?CEc5UiHdJ-@)SrcaV2!dA)hJ-jI;#{zMh@rGl2=pT~3 z{2z7PHqn5FJb{R_9_ZQ8ah=te-c^^jqabf6{D-MENe+28U|1`S6~{z@V87h@qC2>w z8OYZym*sfsI=aK6!zm2%wTx6iSh!5LLkCd9S}(>w0lbhpG@sL6IAC|N+AOjkS6CE3 zg4hS&{fTYEiuYB09(B2H9oQt^NhOBVxt_*0JMFKtRpn6lzBsGr9==Jl33C}0a9waa zUw+H!NMhqMQ4kS~qiA`)MN@2s|01;H-`BvuY*yE<1oDPGW}?`f-RW;hd6B6ssK#xU zB0s!V(#VXYks$~4vO&Du9femN?@=27IttwLiOX{GL1`f<>%G`>vQp@w(d6y)o9CY7 zytR5Oe_w!_jFdGihsbwt2dnxFW>vNFrY}uqcgKk6BNce#W!MSYj^yo@#l@!${Jd~0 zCD&C*jWe){;&tEW_$8H8K0w#^gAA0SD=!x(={~9lPsY(BH*#(6q{z|yRn7b7!PToE z0=9w~B_P8sCDG}cV~l`G$@2+e-+4U8=~A}^9Hs^;cGX(jl?-?g9=JGk7I5?MzShqb zg>P}@*#qc}ON-S`Z*HNmE*x)Hrr;SwEJn|P=oldP#m4^uf}%^_cRWPi+gvk~6}k%s z_Y!dhU>~iPNmW%-ZY|UlGzq5LM6KoPr~(5J!;?5I-i@C-14d`HmXP z_IGG9F$flzCabTmR(zfSRu1%9$J-pze$(Xkcw@RI`pyYp#N;x#4?#)2eQd1K=R`VP z`}@kGQ`{=-);prS;c} z8b28k*rZFuVI*;zb8I?^!IMD z?jQQFKK_>KIMo6U*>dP|`$NpQEdWaG172MRC>sOKLc8y#x;$a`Ko=FeWhA)I8 zDbmLwW);`n_LFGO){Sjp4(6%!wF)o76wfK~3&>9p!f=2O0QW4^Nb@CU@t_gn|M%$E zLj=|*k0|PtS^)$kd(7@`VerW!_kO8>v0!5R(Dmqn%2-^E=2ttp-E7Zq?VA-EF&4bi zkMkuxMOzaTujP^Xy#IJS{q^Mu1HYawQtm)M0HXYK%?DQ5a2iKH3*yL^Asc}QKzy_2 z0F!2r+(w1)>frngOGP*IZ1+GtnYixH%k6*nsO_nAt}-q>byH%W&CZ+ z7)OdK0uvF$2=!)=MyFY==Xav3?A%747j1meY>=%_wR=?2`lGi7_TJ9N$j)|lPYQh_ z?beNuv|q~XN5ztb3R1iKQzgOabA&$49YXkLuOL+jv#Jhix|3?_fKm9(W}-l*ipoYI zVyAjNV2eI%<_qM3qt8qR#?+1X>6%_CU)4yb3+jp$$~q-~8>) zL>hd6A2J(Gse{L!v>jWgw?-vvDMaCe7_XW;ZFV{aB@M5U_e`T~8HZ z_qd$7tAX_`qQB1icUI`75){Im+$KWR?_p*`uJq?M6j8|e8k)GCtXKTL!e)P(2G9Vj z0UUCi{vU#yKYCT*SNt)Tl*FS%c99dy`Zk}IfyAZ^a z+0+d@+r7zRqYLFbQ64P-P4&6&{~;QVIhgJcC7#IGcC*D7fqMt0hf_6k{?kDX9U$N> z0siLDCj|`j0lw27CXnzHzN8OB!T3K<^}j=J*GUX6`b@W1p_5+^1DPKqhptKIb`Aaf zAbTJ9?-ciCvu-t?Zn>{lLq%?70Wn(6XR=W47*tSaDMLy`;@k`Y?9dBw+i#|10)tyZ zTbmLeND1A1x00tx7G7YaUHz)iRR^cLm7L1i9l%cQQB{P?zK|xA5{Upous^R8JAR^* zF^h@k)vmwcPDr!HP}EYot*(Q>9A(!4-aFu{Ub6T1d??p2M!w$zQ4l7ly;@-W<2XoP zfg#o?$~c>Ew4cZk2}9)E=>|C#X7dkbeIh2YSp-dCyqB|appM6LQXyRt7-aR-n)T~T zE-Ts%M-RaIIhZW|_;J-Z81a`;Zzvl0>)JD1{ATZmpv`YdU=6hb*L_!w z(n%K$f%M}60l9AgE^q;wxODTen#udDb%_9hWc%g3EeM{l@7c{(BD*Z#Bt3h6E0e+f zV(^pB{a3_x4?sNKU=RU#;};-3T)_>>JK8{h3VbcQuT2B}tFQMk_GCf-h+z7e2*)epAtrvI`fSP|91iZ&$M`s;zuBgIA9SW_yV#e z45F*bVO#e14V8c$pixk3`{`Eq36OaZe;2O;?-W619qSh4aCI#geXd&g7F{=J0IQql z{-oPJ_ns%_GoTYcJYGFLb|XHil1P5tba((H4?wW@YK%XVLgRO49|eo6BZ&e}bsgl{ z;QG>ib z5Wy#7O4J&HrpN%~n}HnfM(An}KtlHGJ%bp!o(kv?Vs2e9UM~@FI`uCeu2yU(ixhrj z^w0Y_ZuCXmG>Xi;FdetY;R6N~QR|n$lS>pH;E!T8?dGW06zHfYWJX!KJ+1}g_uWmE z=X$pqJOGCtj414~)Y1-X;sL)HC6<`J+IXrmOu*IM0< zosJem5DW59zS^dcv_^{VECtP>jk$1#8HU&yo%0E()C-a1WbI*w0ZLO`!|<@*6^rL|Bd+j!+MYp{T-(r2m`Pk z@M0$^sM1DWlb9D<{o^#Zc=?QK(LTP$^v7MW&-ERMAAOEws_w!){|q%AU`=m9Ccm7L zQL5cCvMZW6W$U5&)#=u{rL0lbKBiEP=a*G5!r@Cec8Wt<%J2ig*#st9_z%ymbahe>=($qFE-Fm?Dv_Adt7#-48l~lPW%^vWF1D5rKue6oaBR>_-86 z?C%&0!)I`l-{UDJ4yaNR0;9GKqj*A?dJUV+=4*EW#!RAT9av|s8mnwnT3r)$?{nlb zc^xK;#xfrc@&hj#NVm}B#)S}y-@(2qJRufsWpv!Jd;B6O{&g#x^=%zf@yA%|Dttzx z&(zU*kC%rFZ<}raI|FLAQokFZW`XS27?e*nMcRHqno|1zyb;E!2WL`F9-&A(3jffyVZy_kNim^GCgn#+3b7;i8Sl>>cc zI2IagUB*PV0ju=^pgcHHQ0+sme!fc)sVXkY-2=Ok&!{&LXP-ydY6Z4S-*3<~EMAUq zp}P7z9Jbyab?`o1oP+r0Jpf=V)mbKi8h{+&E?vT z*oFs%pIoOvr}?|Sx3+sm=5qEA{;B!cD%%<$CYdpN3072R{nQ<5zH+0BB10f+>M61PI zM?ZcnC}~1KFMQ22lheCq?0+~?0{C&%>eZz}!B3fO=e|+v?Gx>)CI0SWf^CEWNNrap z;g%>fvk0>G>kjMR^0ntZIO0q)f3%Z0e~_RZmR&UHPiWM8i$UKORuCF3{{n`Pa8ll< zqviQV9g+p~lqs)g&%yT&Z&PRItz_Mh#(3xf%xDBe9CkH%d10G;3MEp*#bHKoPa@6@ zK(l*XbfCN6V(a++{d<=n69)&!J7Y*)(;(rGVo@NCo>8TdUbQKFVf0ls-b%{YcoU2Z z;!*qE2slUTgqq!&b1_-7IXIdgAKM+pmSSU3K1|ef=`@4xin=N)XlKU4!mi$rr;Uy5 zD5Dd^81T|ESRoY^EAbS?pM7!?vArJ>5)$(2#USd~){V66#1-)|Fr-{2jiWa=(a2j} zkM3@7N!~@ccuEL8bI`vVjN81A2nk_kvk-w2=Kr^k-Zcs)wEiTm#bn3L)}5MFl{W*m z199x@Z)qG8ib_Ais4*~(3}6olG)Mgj-5eDZTZHwr#V9Ye*2JA@>ScvX-v}E1(%UP0sVLwIM6ArnY4}Krt?WYkwBBOb82*JL6Zp2+C zs^Jy%5t#Lv@kY4z6Ggo`({9Cvtm}J2>_6V;|cnW3wo%8GfvLV zzy}Tvj$df^P<)i-OG!yss3asLWLI~$yQcQ02tO1j62CLE(;z|%(mRH#9*8)|0#5V9 zrg+@jaIA^#@5v%nVI`Pvxprc47Z2gG5+no zz@1|1L%kHl6oLAh0l)({mEC%8G-;F-*x26Z!8B<);X6YC#hFUMgh32GGb7)aS*!98av$>5gMLN1+wY)brnmNBxZXz;^9bG zUi-D%ZCcr}T5;E=b}fS1Me6@|lzxw!pC1W5QdbcJz7V{C`fRHk#yaHV8D=n1Y-~pR zmp$q^WfWAyId3=rg5d<6pOl7dpfKbQD$WA&(O1*E+>%ZIp{8 zgz)_OEgs$_!Tq0~?MP{V@`e8Q4;+M_AFAl^;N%nw(wB<*dYjYrCM+xtb@hI3?nMmR zG&CeVSXDFh78I%=D@i(N=Dy}#LCc|HE4Cp0Qg%rW_B$rZ{=Gi2dif=@uT`GGG};%k zTyF{>rXr@**r3@XM{!~mW*0>yJT~eV88N0MtbWdPr=xIcEeR|_F(dI&AdSV%Ye_J77&FEzXDqIALgqiqUj3F=@ROmht zu08dI+h_9mh`PEK5$3Hdv^URn+NJr|3_rz@?n#pGgj?Fu-hppXa>Pf2u!mb zH+`=>JFZs4c~raeKP&(SR#x}hi$RiA)>>JV+W7c1u%XaWa0mz<+HVMw$;k?k&$V$` zZ+$QJ^LqsRY$D{##U`fD#8TSY53lg8`Y4@yLq!HME@Yu6AMOy*&=d7c#ip&AWhEt< zTaziGzQ<=|q-W~NE7;FZOb~N$P!JJOGcg4ptbvZa5iKomUtAvZea^^G(bSZyK1*gQ z)Hk%`H?)FVFiSItiCL>Lks&TB$v)BH$g^;}Mj=Ri5lg-TEz8<6W>!%*w#Id}9GO=1 z+%o}0m0+pX39jm!hr<5&&SfB{fyN5qWMK3)^1Ji1*9vGcZ?}e1W8&Ye2%yzTg>BQ2 zd$4oI_m7Xq1ta7W!-8G_WAaSH%?Iy>ub6(cR$4F6=E3{;@R5vwq?xO$Yfpdw=#m(| z^-8OohnrjP(bAm+N+7@6YNx(YZhX8^>vzdPOq+Hq`0J&ULoQU zzJTvClaVYFb)e`(IPv zDq3vNIL&pIPgfo<>1Oo+PXJZ_6V1qo>f(=%n_(ZqRf<4e*EN;zQW$g@__(-(molmK z22i(Uwq6NQ7m=8^*Bd?;=jRVwY{$9os?!P>weMO0V4l?hw$0QOUqtifyJ@*@wGl7R z=_#Vm^;=GJ3yas&Sv!?nE=TEnK3f1zCrS*>u#@+e&F1DTi7(igd=ps$q)M2C+I~&T z>Gwz~`P@#d0Czvi3L6vCmse9u>%-H7E}b z6Hw2;-$4*HAIoUY0F4BNRpP8;Yu6@-)|SIr_xaDWl-}>VQRP!~JwS7vJJypKHQ_?TmiiP?b1G{ev!yvx6H zaZ!s^Zm=6-3eh&*uOB1peNBU0gbM(&szR+h7v9ef3_!o84~irE zR^*RjL0%}&>D7$FySZ5iY^zGj;lV-84)5nCg*zQZ&7UKQm~)FRjjSk{QeaMQpJ@(c zWkH-}v#_wdbzaQH`o!VVZtU68WOMRku?X#t$0o8+mROEX$9Hsws)B-N{ccux7&IxU z7#M+8Vx+g5-?l*#{4GT^W|pY5^mlQX(GYZrju$W96&IVzThmTlBS#qCT`tkJU2w7{ zyw|RS5r^WrB$bv~m z`B_<*40e6t5+GK_;as|Uvi5!R8^-0mc$Q$bBN7$ExG-jK)MJLn(Q9bvES7pbuic^m z=dl;?HuJUc4Ywfg|6({MgrE$&KBrH32kD2)G{s=(Lj~$Q6;(LY#%P zLOB00a<$-mk;}@GxJKbrNzyrT+h-`5@cw9)u}?4!8d{6|a1jU<{%Z&LS);vqwNiiDTmNA>i_-kIUIfCCIJ`Z%se(S6@soLz zBOG9UAcD*}jbZ#`7fk-Ku{Rh^=?)_MnSXhtgS`1hH;P;#}N^C&xOf`N_~(RgejF& zkY%{Z^9oSN-G%*V-mfw@>p#N{^OXUXg;!N2{p)aMkgn%x=BQq$w>Pi7{aC>fl#HV0}gne~!? ze%HTWH}pIDtS)jm+;uwJOV{hhuEDNJkq&)!6i68&XHXk1@Dyp`&S5Se0 zVebCkp4qFLgzr-LUbTnd@D7VIwS`Ydahg;%kKqa-57nnQM16|C&c?!0AIJiI&a`W` zRJpIVe`H34OEtTpe44=62byf{W{CIh(Rvao85s|trAty;8o!I1Ti~ZcrNV;wLp3y$ zN$xxiEVltRE&TE;r4K9lipVx42<`}YC#cD5=t;I_Q0>4 z%%RBnG|oGmV;&ook+`v8sLp4oIYRUi*e8_k0#Es3a8jA>doM)2SG;t!s*N5&?hJ$a zb9!uSID)2{T3QMxvX5K1H7e@HXN;!QFD2G^7eL#l)$Ev!4DUOeThvBU=8w5EuyK+6 zLrW~`Q(jV6{-IDZ1lji{hlp^byOnHi*$A0GucIKkXJNZXJdF`s$I__z|If z=!RZP%?-=mcONS}jp0qQ`_LcPsh?U_9$o}qjBwdzqiatP&@i*G5QbD6!1ZC)R3$mp zb6t*zh4bTkU9c4;uUz-Fi8qMeF<*li_RRgg|FO^0TSCGBCwuHYwvovl*T9rQ&SzRRX@3F(G>7#%oBKX6nJFJtxWn4ED!JlMJxN4Jx z0lcrUn9J~3a$%?=5U4&B5ve~(CnT(=K1{O63)5B>Fk#$O5GrMU#4h6~cbWCyVmBY} zY4bn?T&#W9TU5a}#;DytfRT@+2c#Z1qHeif(^YV7jVv`TCO>Dn@R2(T=n)tq zFyE!x+S)WVH6!tcxx8DWORo+@(DK!5Uic^LEVeVae1A{jcB*3-a7*=v8~^X8(L=&S zLC^j4k=?iB=`k@hbVwv@D=#mcthtDR_kiHk^l9~DS>6{p2?>o#z3D6*j#`BSPA0}` z7#hiS;x2a-LS|mxpvL@X&mNN~gc-evUjF=v^KFPzL%|*!-vUmsKN3EJ+YLo~lqGg2 z>$^re0{Syq81--jbkARZyeE1|XgHWK1*5E7v8XtskXpg2Dup1wJbMVWeh7!IfNP+P zj8QV1E-lU@!*Ay1A)*X&p#Fj(cy{)rp)O} z1)sIuy4qU?S$bArE9n2pX85md!w(My6Q%qj9Bp6RuqTMeBzUX_W7~Z_8fCF&0upy< zKNlL#5;E=NLq7hrnEb~-hY)U|f(LDKlBWFhYb=%^bS5B8|CjV z20|gUP$c09NyPc~p?`82|Lg8A-adoLeF##75Bd9l1BU@F7W^${oE?O2{t(~#$CtMe zz5!$2Ld8LU_Yc7LL9ow~ym8D@z09qx_xo9=;-TSSA<@HnZ|)7S(4S8e{Q2kg;!$_- z^=rX$YiepLEAxtyy(l&%GZDN z1pj;TMR!8Lv6R%>m$c<%W!xt25T`-T3zV>XX=JqKt3l4*C>@`K}(!}Ew!e<-4p>J5*m?K zc7x079||T7oSdpP(mjP1%E}LiBkCD@gK0!wEqV6U|7dG-8&{zzqhWn=rvVTDK9b+i zP|4a_+tAQZGqv67J;FJ(?VN?v<;ls(<)x$j?gkyKsh*6CwvtlZNcWm|xgCP5d*tcm zXwlqSjpHLK5qGTL%UXHQ&8j-!loxS&mc|`IY zCvEXCSL2ew*WP7+xRjc?cMfsib|Jsj7cPxVX%y(JNG~n(Vvi*$?5{@`wBfdT%^q4? zqqjb)k2F{z2G1`PDCx$Jcz=KI?xfA(a-^lM{-Uc0M1N~)>fSpA z<3PiU|FzUaUp;&CmU%cdbYe%{u>WOCOUp0MWi=6HmGt7GqP*8GzV4)l9L>n;C=SU? zreECX;b7Jjx=bOq%*@TrcOBTQt*vEboU1BZNVOKzKQw5vac>>%jU6%e?H))-@eAMG zv&h)jXlO55RSDnpV+^YG2fd-Yd)U{qudU$QU_N#jcHmnG&-MC&QTuI=(eywuL~Rds z+?ivZTt)?r!_>+VKEzf^!zk5CrpcjOJxo_EwPoh!o)bzc|1)9grDrrHM>UKc%kfl{ zA2Ix%s)~w(`-@kvIBkxvoV=Su!{kP(-{a8H$$1Cdw!yNnr$E=9hxrcZ=y<10P!Jj5 z7V#;V7%95L@ZlA!KAsdwZ7=j;&st_tQv5h6b^tNbfS(3~T=) zVDS@8cWw``8_hONN76P+p!32tzYk+uEyEU+PUl;xWwB_t(~4X zaNEW>^3eHE#%nI2HEIH@JU{b-t`n7GI_mLYZ(UT0CV+qGmhB4Bdmo#(iif<9wn-z+ zLYCuw0=vg`J#mx;^0SW+l>CS)*?h2O`Ishp{QA1_$Ctw*@+1 z+F4jT%P<=Hc66R5rh2KL`R(nzF)9((FefrvtxwVL~ZkAe4&y`sE`9v-vhBSM+W z!u6}GEe9UlwM{rtCy}fU=rrDl692&WZj5gZ>GCZ42|ABeg@xI#*wmh!zWtmmmNe)m z2>A80zIb6;S+59rwa&-P4Ep`s4P!Zs?hO?xyo)sMwYBV6;TDK|?Z3_e`gj;UJ3|*- zhq?~jW0O25C-|u;)9&v6n4+xIoOzyG=&Xc|1xe4BD2Q5Pb63)qtp|izWb0E$Sfi00 zZklhsj}ph>w&TRo?UQZItBdpRMLc^#9ybERFGgf*Y-}zOE$O%{;$%ENcV)K?D>@D& z!!gFG)&W8g$wQojaB^GLhZHYDckJfEsz7fFLsTI;%8=gu+PA4TUPXk|#V?M%bB%iK zQ}Nr%V!kQq4WF8cK$SB;`z}&aV(IS>nHQ>KKABtNb!0^^R9;#u60qYB(P8(g@LFuZ zs~uLd+Y6)o0!LRc(wPU@KlCgrE?bjoqkLgZ=bvm$zpw9nSwk~KoL9~f)&*acsp0Hf~~CGK~AE6%ojY_F}{)LpJ0*759aDC>ov}#kV?A8mmi0 zTPltN#T4T*N+)Lv2T6~m2xy47avKuGFG}wfJ9|;5(2+6CAYIu`^1H{Jvba=za)W$3 zI`F>|#jM)Lbxh`2TJGPZpP!Apxl#xc>vIW`(RqX*y+IkD4x}JYZfT^sJi%l_*(&Au zD~i#%L+Tp?Matd>znm`~?bTjho}rbVM45F+)VcAT)=U(7p30PufxnA97}YI?J)70k zF}Nr%$fwP%()w(N3{7$J`%g4h{Wn#yK1a(-!q_6y+{uR@ng`6kE?Mgc6lGe!TB*(8 zG>P~;IUaexcQ7e$JokWTDeli=;vP2DTcB%D+I(efG3%7zclStp)O>g>2EMk6BI7@LmRQb{CD9_>{>h=AZ3ueR_F9qq-jlK z<(kALP!E8~;xn;vBuw@&vUt0jz)6i6T+KW^XA&y((2upeknZWf&WASM? z56C}+<(=8t(}#WEd>Ul~-MHo7Y+?CzeRA3$xv`YOAoe`d!O(NQn8-mP zEtPNV1U%9F`|`Ab^*kUUM#mA@oXc_IaQopTdAdJt)Ek-QSe6xpZdHd?xV)i5wv zMtCYyZpgWpYKXcJW#hzI|LykjsZp-I{vCHOX*^2LMEz{8e@=c*qH`Hi?t9ftN7AAl zcEvuix;S-8<|=C|K`ufuOIgjfdz?ubk8a$@lhtW0TZ-XY_iJ{UC*`=6MppOm)G4sH zygSUygj06UCPl4!m)kIFrJyd*z{9r%`{JSlI$RGD^En=us@=LU)=mGZ?`LLnf9WUD zlQq0srIIh4N`Ui{l^^iMsFvMWOgJDgFe1U_a+J^c|2T(^GM(j-zs9NyEh&okz3W_h zP%#PgClfsfXA%d~?b3!LWXm(d34>&;hC+c&3fpDY7d*XjS60QOX}Ua#gJT)T7=vBK z+6z6|``}4*Erp8~Fczmi!cv&l_pSjAzp54#((%Jk$$nr1r$f(=qXJG{G-*{!O6ujW zhs%D4*2DEU7ma=|MH=P;evF`t+F-|38-f{WZx*~|=1eZj8i%z&4( zh)_eIN}654CTe8)Xn+4BH6A)Tk;kPsK?&(!?w{TXG(ku~J}w7Y#&g|JJ-rSTPuOnV=PLoUn4 z^O*~H)iVO^yQRKF6R8OfDWSSvrh;K~y~(Qie)!qxXqMC;9Gk~ZBHiDYY=$49U0fC> zs$zB@ukmJPvC1ON<9XHZ?#WWME#gq;;>>yuhI*GwP`?06s*}L?GCtjln(d0c!0qVB z#@_z)6A+EK?I43VCljTG)`zp}*;(-rWKR8;;hkVen3$r7M(=KJNZ<9|+}@5m85^T* z8%YcfrgL4zAvx?##y@)-#fj|-NfHkdIRvZ+J(P84ijyH%DP<&&pO@uA{n818ol1cB)cRCE4F=>yj;Gu6U4EzmfNJcU_;JF1?oz858?GQhA`b31ze) zoubOb`a?*{q7f7ti(js5J#%b&vGv-LbzHdx$3>)NS6X5ssb*tHDWY~Xdw*ZU4OCqN zb1Dn1hP^YVM_1EMbT8i`g>gQQQE?kIM^dC!C3+r5T+}ib=7QXUYBN`b)A|d!+EG=q zk~aE+QbW&l>=89=OX>`7*|Ikc>{BY7E|#wW*}>9Q%|}>|1ZlO9oQLP_i`Uli%oL}m z45(z0_yCBEP3~^y?G31)=GTl#2`Xva$c$3-+lLPt8l2BVLPFqRU>IXxJ95z;!88E~336``I^^?* zma-hk+<&;9(IWb3m5m9|8Tq@@Lk6bR^iHFq6ow?mXYkg#&bFwhJnEE@TpPMkFx%H6 zWs$oF)_1&3YGtxF9$G)9z`o-6Eo}jNxGzTat!4kgUE!~5og|7GmdH~}2doM2f*wUp zW0*+u!u~TupnJB-U=?#k%&gDanZB@Eyh(qat1%|=){^GNLqU#jF1ed*y|a1-U1QRq zMz(kV$xu@%*gyl#Z%q~gUqxpEmrtih_*x{H!@RO@hZEiN% ztyz&MklcZEKhW^33QUM}1iklnQ%!wmp80+l!olrlIi5#u zg9%xgYp4r?{S8j~2@*;XzY_0x&`BnDip$D|(S-~*2bvl=MdWPfp5!c-@wR`Wq5B8- zjhJyYF5iB&?I-zeS98=5?q>Ba|K(I z@6CuF6uf5v7v$uU>0AL(7{?*oqGNXsYn|Fy-8-xlvMYA--Xva@hOWZy?vf`WLZ-(k zBrI&J5Zdk303FkUMu*#Uy+;`C0{YoU@lYSCA?1d84WP*K1Wngn+w{nl4Euw6`uczf zlZBGIL`LE z!8;mE{6Q2|W#yqkx8l>>s`c38{r!ZL6f29Y*)%-v&l1u(<`blmSm%Q$vq=`>3$GF( z`pNk^d<~gBSl@?7sVImFT$XV-m{=_~Mwkttl9B!z&dy+m`%K~>NkT>ziM7F78K-3X z*PcWG$qKnR6~~cPeJ1U8vm5c&rBtUaK51P$7YGZ0PX84mN3i-Sq=ti~Mxlu}->goAy1qyvel0+n8dAX)_d2Ru)Vm3L>Sog8y_KKtnkf z=f5$JvQ=k*uA!+9OUH0rUeM%H%RJq1th=p|vdg_+ztwY7_PIExyuMDYD^{c8(b=#k zuilU={i4p=-Gy{KCy&pmA~H-t97U_n$i~eb&i0`{3gay;;?}b4d*|cj^8w zwMfD-9VE`^`nuvW1hFw^y#(AMP%)f+eWeMqezPOmZ~So81bFv}6j?s(RT7K!QX$Sjaj3T?rlX-*XuDf2*M3#W zeW(JPW>vKJ?wxVlm;A1Oe%B2{C_d_JVS zM(Oo)yP|KVbf0e7K#}bVb%51g?e|ripD~GIV;2{24w!Ru936K{#|{n-!!#PZ9DdJg zG3z{rhy^leY?p8I_7lk#BaiDw+G;pRBCU=P3UV*AGRdmbU3iW)QuKRe9Zq>y5lsvpK^a`l?|JGDwVITU-z~WwhqcsiyCQdGT)-9r-gf~IFBe}(YE%gEQqa* z!B;9u*+W?G8kygP;1_p!&poBpQuEzp@kEaWN#(b9CgZYs9Tyika2J(pf^S9M_fEz$ z%OsK8E*$Lbt#53c@9Xll+pR(7s@WV>y~6k0$?#~uUv!WIwQG4{&296&9)hA%C6q~& zt+p$VfE0Jxes|QS?3dkvyH?Tus3kAYmhEh(A>e;;b@kSQX}#6pb%6bjUTpa5pmQz^ zJJoQ8ySqC(fkJMRZ0)qr$ii37{sCp&Ud_l)GM-!5985{t9G!wKK_uXiqv0J1JlXX@ibO&eaj)zzb=77RKa z&*IpRb#0ZEJD`o;!o}_csSIbP-ernq`Esk9(i5lFrKj`tiTw=xVR=cG8LFu*^zE9{ z*_*Ln(zFhAduz4(sXSf_^*kHSR)uxcGaTmj``0%#w1$;Q;k`KNU0fwKVll zVazifG#wU8ZIl%p#LeQ<=_0#Ic+M3)!cawv@w{2DJejbI)n{hcBgI;k$ULmS?2|cU z&E=_u4#`iF6eYxnWYyPa%-DmRJy;XQ#<;}eHpK-^kMNQ9)OdWefxDj@&L zuC0xyg?#?}{+=_`WqTx@ii(O@$_a>zUkS~mq+q*~u=~<{OZ6gr3BRcTf$O77MM)|D zQo;of$&NQkSxSm2+xoidr-t3_h5479y)!92W=5??B4-CFKdUx7{r6X|$t7oD9bl^s zdjB6^UmaHEw(SjwlG3s0?(UG34oMNDLy+!nln&_*DJhYbF6od4>F(~XZ+h<-I*szUpyF+!3FIX4|ADvHc!!XXcW=>D+#HS-kH=6`rd# z9U<>#Hn*_Ye?#$b*J+Q*5YgItdU-8Cc~Y=-czCFh0Vkgk?B(S}boh+rXIEiXR#rtt z%t3I<&i1sjMy|D+8y|LiTU$7Rjx~B6#Xx_54hzl>%#VSAf!SFaZtlE~8yW!^Zh0w9 z%ggWJ*lrywgyBzp5Br(U3RSV^9N0G^T`ujPtDQAnR@)LZT26a1V(@>92$!sCqL5{?N=FT?jU)|j7oW;(QuI;ACH|>m?HdSNa&W4e{W3jF10F;na6cUBU$j2*X?+3f885YlFIdpV%WNLc)K1(-^Vp2@8S^!yc zt7`IcwzQ;KrNMyVZoAWzl0ub;l{u5bQfxyu7~e|*q?gn$a+@!R161>ic!T(EHtLpZ zC+*T5u-TB9k_zIGaDIM@jI^7Z9UczWPx-7Toxw)=t+A2(LmGJon2duT!w!*@UkzyT z@pWGu2s-Y6@Bh6i>A}E8oSdA9#FP{jHBZH)&o*Py^r4s5o$iXY5E|;Hp?!@b*sZFf zNW)&^aIy?ixZHp76R*btLpR1jMqWPrOY6gZkegq}+r)UUaLmBnT)Q`~)hEk#$LXwn zwCZF!Z!(@e=fg&K?oXHp?eZm)N$;G)I{S4)srB)4y>EYZ!deTc(6G6zcg91+`N@$O zZ1ONM42hJN)+>#=g^DqQ^VVIp4m9$@oHD$C+w?>{L!x2|r`h%>l}&TqgWlu$!_v~J zB;RY#yJNl8c5h&=D)e}FD!#IEZMZ$!35bT5t_631;pKh)6*z6(sRKL?PlM}Pv=j$N zB~S`ob4=dkl7R=ww)gI^Q0aMsOHKms?@4y^!0aBd3 zOox{(kL`dqnbitNhZHT3_mB5i-KdPZjjprR-z+8{*0O?#wX00CMqo0|fy@S~zcTR< zjbg?fFzua07QQ+CEZp;I+i6ngBiuU!gRF+zB(<+4HwP{)``goDzE3uI>)$M{<4eBk-R`5VUr-aFw-q`-$uQ*Z%F7n;Xya z^Bfe2oA>>q_cQ$VF>$_&D_V4hpUDa_)I!tk75Yn8KwMfM zFI1iCS@5~7u{lw-19%<;)JWIWh#K#^)z1c6eG;FWIy*Z7)}`ipeJK%n!hW{e3RR%2 zIxA}dcns0x7w=KhCr#5$Cy$Pfn))%(e!C02KaTX#t=w<`6h|4wAy0r^cBQ(SGHMY= z0G1QLYUTC9csT|>{?6v)C6A@a;ggn^)Gk$xKYtRg36wdqMyw@O5IH3kbDeIyYMH{Z zS%RC(l2YL7aN#zLn>0vVl{()Pr7yX=reCLG~h!H>#$RK?Hc{UuJZN45xP;MYQo0uQ$j9K-0Eo4GPF8!mnc z(d=x{t=!4|q@{S(RL!Nx zo(Z#ev27E-eFqN2Wp7HqC<*r~F^Wd#Uy4g{Xd=gJ2ABj-IhFkRe!7H*OpcH5PdoU& zmuw_Bw%dCWu|MlVDC|Ag^ULFOfQO9eV6N^qkf4MkwT%GzD^UhvG^u*AMje>b0L=5~ z>gstmBIPQ(j&K}+tIHaooS|0Um@G^p7W!$yXw9Yma^be5-kd7(D`fHR;nL^oX0inj z0`ccV@iJI(0ILBX5APTtwPR_HL8^Bkr+0G_#v=4ND$4eDzsB(LV}bkr_pizVt1Fg) zvU8FcJNrNJ7Jkhlr$R1tSyl11K~m zNowcSVGKIntp3WkX|}>HmyV&y)A%nMuuI)iS!eXgAO-m?V~|5iuz-Wf@& zHavS6I7l6ocY7P(&^@lRDCXtox199JNdg&A>xOfX7oPwu;SX6$%e`2|hyAUQ9N=7w zEc~qMXj^gdD)#f;Sz?PPpVz#=WX*Tc!etAeY&ADhe{?T>BWxg7L;We+`W&~Ehr>Z-Clx1l1xqJXqG%#GzE3C zdth1`w9M-4DV=@sT7mmB3^_75BkPOlybbwgNLm^;Eobh5rRC3rm?oqXLPdeVdHu_A zOOE6=3`)xRS20~d#8%8mjAM%Hvy8N8ySPNLNxhcQ;Mmp?o(d;)1wS@O8!e5bVfMat{L>#z#l?mbMV(!EsYz&Z zgeD5~%=uNV--G4Qcq*_&Ot^}+O8+di2e)>@)_w;Pc=(E809e`J{Dn!c*%#gn5FY>^ z{d4L?aIoFGCkNbX>zxA?Pk%(K&yD3P8WByiv9TQk`5#9t$@w|$vfOJxfp@t-=l*`i zpjQ_ZM2#$m02{RD3h3>{dXIys-beSDq*@A}tY)3#5Bjlm2-K{&n+hnb zz_-{g#{fkGG=sQjKmq=7<=Y+Dl+w^e59)VInhxp-7Tq@IZG3LE0NgQeAW_#nIR>1U zb8vBHHf4ToJ-yBPe7E)<&}Z%eyIpE^+8JPoUFN@UDEi#=i=p19wF#uM2EdWrO@DY= zjZA`kmO1@1s(5!(vOM>QwnBiz_?HOGTFprChF zu?$VWAa$RuC)q8=TQ2mr=Zy`5h{p2qT*7$4u7KSA=y!aOw${qSbbXIR%Z4{^jBXE) zEBlq0^`H^;&)M_X;TeB;a!wHJd&Md>t2k3j=U5LDlKWwZV zxo}t?AM|=gBkau&tHz2S8IHWz@(9m@jBJ4Ah1W)OcRcXy`=}l)YsnhdxfF>VLl@Bo zT@nSm;bCu^1cE`m4YaPZW*rx2j(nzvy?XZ#t^j3~p9T~*+=JI;uE&Jd!+bohS{+m$ z5M~tPPcbFFK1V*|V8WlmCn1SXP1V!XwB~HvminlZHgqo`zyFu6nJrE0M>0(U=4J86 z&2mnvPPp5^)$TC(O!1Q&h1E8f7smGLN8xT0YU;~a(gxP-$81s`7-?z4LqfQV9NK|L z`V^h2khgpiw-r5iu7rdH9UUDo*NtFlz8c*a(6X-I=7{sz{*ss{FVkswwo(Ep{| zuL*Ty)=CNrm%Z-HuvDIj^a7Ft>#4XA*Qq(0kz;#c&A=RFZe1n(eQa2&F_K~hBr3Ze zzlc@~0%bH?`5rZrNh6>ADW~>Nukan(B6+wLkDvM@?_XF|&PHrK>40>m5;u$hHcw8h zJ*n;InVLiR$k?e+l(%Yeg*pkW=K10K@dLIS_6euGVa)jm6WD%alC7L_godj-L(8^L zm+&$^6E2u87un(!ZCxu%NJ^s3#rAVX=c}qB?64ykpSA_h)@7u;wVn!=m55VZh#~u+ zP5beY?v3Uf=VMhrk(c^PCAS;83w6ij=z?Q&quZg!x6$e8`}_MG*4jyLpLvK-Tolw9 zzbl4vez?Pf|BYt1gWX7EWGQX6X5i1*7gNHPxPJMG=e4r8otI~0#1lW5fMl9YG9Y5iGHG_6cTw@}g+G z7L3mpcX@7h5iY3DKKkc|e?qxD7Zh%bwrfNF@zz!rZSe_J+>;J;J7;SPtM9$P2&t>} z^8t?H``hErSoStjC8G9sDcMlp%57_2L6jH;(M66Y*s*LY`PC6{PIFcfajD3K^^Nyj zdshM%Iy?I&2N>N7cTAxFMz4Utx?)jj;J9x+C9O^zo4dNc^1MTKHBgUy=(aezI{pB} zze?Vox>?`VB8X#1G<4`KU`d(3gHd%^oM=e+tnJ_c*KXh)+N&6aq)!BQ(XRM+ons~8Y8A-mO+tNl0BLAQ5FNFR{JHjJgdO>RXkusayxL#PH?{qC(;9 ziu3Y*d3*Ddy*jzT2wTng`BW!&Pm*MYha@2}K>{QTU6#9CHUW@CzAwh5OO4iJ1ml1RvKo2Suo4RDNhl`yv^ifHNCE zHiFh1^r|rFFMA~vEOWJc`J%;n^gz8+X%3nFY2<$HJnV3r7yhSko0fa-XGt3l#<<^N zaWi6ozgcOItLTEhwbCmamu}5DG4DfY+fzypAANehE|to~*P*RVJkB_y+LW26RjcTVBHw}V8 zU%DVFu@&Oe86et6B?SlVA0JH_u6t)R*4}Qq6`pB}Mc7a<%ix54j(Ph?A%&8o3JG~- z%E!Qf6v0AGoj}ZXF<0+IaCixbk&d$NH#0NtfkvLm}Vs*`eW+YFO0go&jVkN{Q=vI$YDNf1cV>Csf5wIF%gH==Z*0xJL&}ZmF z5GjzddY=;RlWCzJPzYw6@|BLBk}WV9G5x=cfyLBwT>@%Sr0u9T1B9vbv9Q z1tdHP>h{3tpC>80B2k11C|-Pi^5lv9b-cEp^q0rG&E2jkpwQ9sen?k!X{nP4x{}2E z3f)j$73zE+PWZ}4QQ$cDI3sG8I0gP5KjbkSC-7EJjG$>b@M|yw$QP6;^TCd`%BaZd zRMC2RfxRTHV`e5L?c;CKXC~<{#l8&MMSJzT2|yZ|{sFx|Z?niWh$8rfKyo^37L_S_ z16rjtOJIlOp7JSUTM4V6Al%ryO6DxHoE(Z{k}RBO;q1>id(UnWX`Vp8LpbHPGhFG5 zCmPekp9fXLlV2$W?4QuNJLf*1zk^iu62N5BW=2x@+9OWmsuO6g;AJ$@h2738!0$Aqe3)z>}*CQlD zhF%DcGNPuM7CJe74{Qv?6zp9AV=yqOtM%UT21xXJt`aP^buO4j)Yozw#!qiY;V^5n z8g+#Og%Quo13|2%tg8{J$Do$A=Lc1uEjAt2j(~kG5cV8NEpGerqvnrF+7biT^pp~V z`%h}mLt9zNeJe~tE!s_|bdckNLtSB`^ifplFwP+#UgNG$cXDOru61OIZ9RpEUAr)Z z+rOpB4nx*_rN!^CIHwW?n12gl%rtstR z%y(hkx|5xb4%jq0$dk8s@J6yu-cdMAnrC;XkEDG1o}P1{WuRebXqZnyqDyd1b)fIS zBi=QnT)CN9Dk<@Db44(hjTHrUcNP2 z1_);Y$8wySZJJv??jP^6@aqy?;*bnVW~wHTzFxOpI)V zSf4fNjpNk-YwLrwFG-0Csm?;-f)`n|8B-`Z<%VKF!-n!?CZ`(H0%3lx{o~2^GzmyO zdES`N4*h1hQ5j98>fcfjN6}2x*Eoak2+n zK8*@7)fpp>li1`ri>pxfvn69`1%2~@48hrQI*0W9NsohTjG6Tqs(!Ykj>2GM1p#$@ z9O1cU@5kFkCVG0HmDLiVA36ZKLFF$FuJ)euXxmijzgxW;iF`PnY7Ac&d+Pn%i@KW?fhX+-j}j z)YweG)xHsi&q@0rAXr_6=JhK9A{Pc9t_kCbTZT&Z zP<@h#fdL(k=hWq~1v&{yn90aK>cq~(SGIwb8}GtcIP8;BA1_1DT>kVl%EIS3ObU?n z2rds5zm}FZTrNOVrliEhA;u80z6~G@K01nWq$QCpsDg-bKSPLIc%R{YV{_Q@D5@~1 ztT@C56mz(Yf+Hh&auxBH=a;I?RC`!8bQPq3NW+*#l04ek%6RY7eojwsXXO`fb0*CF zbsGTn65}N{###*3rmFm}2`*Cmj=so-CSGWP42J?}U}oer~s^LcT%bL)yJIPW%BtHjYGHmPG_Lju1<5$PF1oD#$C zm-EXBY03krIV9&K#KtP-DpU5C^kZ^Ee_h+!I>p88Zky9C(J)}6bY*6T%_OxF3~bjv zkjCv6<+1YTBSZKYW*eFBV`~c)Vf%*kqoI`cn>T*XQquMUt}BxXeU+E%hIA^7Tr~ep zE&!~_tq;CB^OZ-YB6!`iSIkA)=9A#rJB>n?+845*dRG8RFFHG$d^Kcg!K3Aah>T1n z1U0byokQd`?{}!7+&RRR_{D(T8_>s22j~G3UR&Q66LhM1OPinIa+fot`$4b9<7T_% zq&OuyIs|=atv5D-@vY&3gq#;}FoVAhZ00lgu#fHpMAigYK;|$H!Hz5`Z9KtRrk)SJy}4Wl}CKyvdRNxUcR)FXIVSV6W_Y`}#mVlxleM%d@U+ z|Hh;wh)W^N#>Er3PkU44K7DZ^NVqe)^(#0L6U6n9Cenyb(YL+5eAQ07 z8B}r`PGyutaX*j)6!`im9ccF4aamr|w(6G93liFbzU)A%i#=2Qa-f=EV^8D7%&fNN zZ$@a^+?aHZAYu65m@JN@t)~<`JCc2UC=KC}kkTb%E^kjyIEs%7NP6b0gXy;@Kq3JN zPMv9n&z6+v>(dV(b&Bq==LahhqJPz%dtEI*Qb*sX+B6drF~L>zODJqjWM_}AtsR?G zMD<%8XW!iGvzb?iq_nF(537@409XKuK&Tc@aRne*AtU!9yjCRPy|!$$5W4O4fl)+c zys3!^^I-1XL%GFF)$6v?h@@=`@{e-94&l&AEiCXkKo{w%nue+qeU^;AFM-^17+3fC z$pon_B|CF?{{H}S5|x2TzV|7t?FLWl`eJ%I2!7a0F7#=*+++*T!~$1f!gP9ut-I?s@*US+Q7>Z&r&#?2pv8=2>-$JZ3-jUHU_fq?gGbwh>>iNVc_oKqZ!^ zxoGQbsQNn>1B0{)Ho6qvNkZS{0b^-InwO2%fz0Agz1bes3~6Ji+lkRmsVh_ARz#%D zlq9n-6dsV1{99M`TwQ7F>W~m+hx5asU?2y}gP_-3dSCD?w^t7u24JXodkBSf(-Plf zN=DC@V^YqJ4g+Zl?CQ}loB`?!G&@R-78uC*S~o29D_Lq{%!6wZn;q+jufHmcPbnxo zcD^m@c+9Z;6{d9s>&n^69OMI^!^@kO>zRgQ=nE&sIRumdjC^0Hxg;uzv~8_(yhb8t z#YaGUj1wTsMI%ciA2H}rd&w!YavvdylX4nQBcebs$W!6rP%%RAEEgI&&0dV*9qQ#f z06|eF4s9mB_%lf~bb@v6Q&!i1(N@Ji0nQ)GIs^&^SNw}} zS`1v~3nw;2ge7I~WzL4RJwQov9{jPuIA1-T+V$Gm7al@p*w8#<`|j0*hi~8)>pwdR zBCsWtekC^Z)BU3h3(izQnbkpp&=@5%Lvake=m9W|A8aS8;DL+`Gjk)BnT@$QUIoQ? zYv2oL`_(A%g`X6Q+m3*AGSUXGxUj8jMaTx*<03AgWNliX6y4D9V)|?C;dXYo^IxfI zfcr=(YG~);vTIm1M~IQA7sA#6*n()hE+yr=nA<0GGLR{ z78n};)?|)}Tw@vu72H*-&loQ?bkYagnMt4D>X3k6=9q@kO^5 z=5PM||Nf)N2f)B$lG-PGFwdy51W;^qrX=w8@z-HpHH%9&l-n9C2*UbvIbZuLQ}UGgcM26tB)FR!sW?s=k0QE za9PQd@Dx=^Bmf*)uiE4p>hD+G%#ty=DzRlhIw9#U=nY zFpL^lL4g4GsoKBoDB655!E%7R<4RRs#VI8bu!=k$+w4lAtEt%~23tDHhD@14IrLPp zuz;N+9_qSwFx_H>??XNUF(NoqHIH%-y)%e=pHW;q$E4-#a8U_J|6JVMgBgNOJL59! zkh;-s-F?)zFi)t%U;65??9~;0wXiLz(S%#{_v|6YdS0vrK)-8)Kkhhqd7)_Be?B&R zGsH2qw(c_Xy(OD9GyR@2`<-1LVM;L&XAt_p$kWTq!^t5mTT|CNw85P6L^oZcJuW_w z>CYfS@ezvnOh5-iJ?@3bT6%^eqMq3a=IhsKUGIXr0jF6|iice8gw{`;VF+ewU@4Fn z%7GBpVEmYI)1uP*KB!Seqt@2h)AQ#@jvI*h5y23b^9i}|_eL3cSJth@P#OhD=n!() zXDSe`yq_;ZQc~P?<&Nx4f<=Z|1BsxSXTXtdS)pL4FAs1aDe^)G+YMaKg!%N5T&ku# z40-3?8^`)0o8}A*q#@TsUs39K{mIVt%aZbauhF4o{aF7z)3LC8RZKnsCaqUfE8cra zB}%g0c{T@jqlt{nB?%W{}LFI~B&?e!}v8x#@?DSUSB;N+W&WA*-xQCEX zZBzi|7X&1vYGJHtN5f{#@%@Q0bW|hBc!qawvbW*+>S8-C8Rkl`H(^kEU74A>jbpo| z-V>kTf%2i-mb6h^DW+|xPY&USyua)cIYCgru-Aa7;#P8n3BNczavE!>d@q5oSP;8| z15(+u6+gJTSY%a8WK>kD0#mJ2CJQD+Gk5pFi+v9Oux4QRDq|(z+tD%A8yoA99ij59 z7o+rNMTP44x}1awYW0~m@%I}29|^O{XO3~AbiQwGUJc+?=_~{t*##5IJs10viuc#> z{QK|BAlPRL5D6{H-ci3N+q5CBQ|bI_A(NCq81VH~@mtlhL-Alr%K46vkf|upAKU?A z+E}Y3&vM7xlDI*>hy6V)WaMdcE09d)W7TLh7 zIXKRFpmm2yO{uTFZ`!lngY^oN<{nsZ&E>S5T=1MGLP3YLP?8p-qB>67$C?NZtaEp2{_-tbhxU?2-N0{RRAI9-2zM_KC-K z)7$d$dZEI(BbYQkzIpRTIey2|;0IMp)tR`(E>^V?x)mp*#bcXlebDgmVWIPnC8L^rpm;DIYsbDWwQ zuP4{mZk^a`$;{9(3zY*`f~cs>+S;W0dj8PmwI#7k{r z`s75F)*m3N{qg22H}wHXdV7p!RcY~?Y;Z@&h%tb=$ty(?z0d{fE10iGdB}Ll+wi>) zItaXW3OAL_CX4I5rYiyrHJ1~J8u9W5vk5%j zF@$fPes1wP--!n-i*{>cttcvH+$e$c@f=cD0n zn8I(fPz=O6qlJi@=qM<5#|!&FR2Z8o7{xN&>Wwu9y%Zdu*x1-8x`?G*(^^;ziFCmG z&VGx3d46t!#E_2SyAO_RWUH7SY!t@2HqF=8qN3<+43)HWe14%RKwzm<@TrPQ3`inI z%-KlSDZKAp7jq^)5fOw}KBn<;uqHiys-siXpwOKe@#l2-FNq;RN+7;g;3a|s6K=?i zkInU|7^o4{iEG$Y}yt!^Do)y)8H6oYQS0cRIM}MhEE{f5MWe|sh%rsi2;G~_; z`b58hZ4l+7=y0wDH~t9#6^f5l5 zkUP^CtT(Du+Qy#v6hcx)yX1)EFG*ZfV(Zyed)f zB}s8b|C1LWZ=uT?%F9!ZQ3TurDvwt6-a@liWNK;{dS+#q*%+zE2y^){0I#=eI&_9o z0sOA(aiAQILQvOAYC2Ki~<+dfo0{F8+4YimI=xbK+kN;~#nUU%%4cK+FaIl%9&kM;8X& zB!!9`0&5McM2mDts#(5SPhOW^d;EgXp6<~X+L$d6{>6fQ%Z6-E|#+P@$jahp`pphM5Vv_N`xA-A-pIdAQ-qu_ShL82Ua0V z!�q-LhDi%AC{w{8HeH{h;!U+(d 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%0A2zPr=A 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%0DETehg)k#_eJ3K(qnKoK2ONDjDt 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 TSFTHOVE_V8S3j3^P6 Date: Mon, 26 Oct 2026 16:22:57 +0300 Subject: [PATCH 05/44] Put the test-utils bump back, and say what the failure actually was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm test` really was red when the Audit page was written — every suite failed to load with "Failed to resolve import ./diagnostics/core.js". Moving @nuxt/test-utils 4.3.3 → 4.3.4 made it green, and the commit that did it drew the obvious conclusion: that 4.3.3 is broken against the Vite 8 nuxt 4.6.1 brings in. That conclusion was wrong, and the evidence turned up while moving the branch back into the main checkout: its node_modules held 4.3.3 against this branch's 4.3.4 lockfile, and all eight tests passed. Checked properly afterwards — the dashboard's own source, with package.json, package-lock.json and .nuxtrc taken back to d620b82, installed with `npm ci` — 4.3.3 passes all eight too. The original failure was a node_modules left over from before nuxt moved to 4.6.1, not the version of anything. Reinstalling was the fix; the bump only happened to reinstall. So it goes back, which also restores the lockfile a5b108a generated under the runner's own npm rather than the one npm 11.19 rewrote here — the three files are now byte for byte what that commit left. Lint, typecheck and the eight tests pass on it. Nothing in the Audit page depended on the newer version. Co-Authored-By: Claude Opus 5 --- clients/dashboard/.nuxtrc | 2 +- clients/dashboard/package-lock.json | 16 ++++++++-------- clients/dashboard/package.json | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/clients/dashboard/.nuxtrc b/clients/dashboard/.nuxtrc index 472fb3f..6415c6e 100644 --- a/clients/dashboard/.nuxtrc +++ b/clients/dashboard/.nuxtrc @@ -1 +1 @@ -setups.@nuxt/test-utils="4.3.4" \ No newline at end of file +setups.@nuxt/test-utils="4.3.3" \ No newline at end of file diff --git a/clients/dashboard/package-lock.json b/clients/dashboard/package-lock.json index 5e211df..b3e249c 100644 --- a/clients/dashboard/package-lock.json +++ b/clients/dashboard/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@nuxt/eslint": "^1.17.0", - "@nuxt/test-utils": "^4.3.4", + "@nuxt/test-utils": "^4.3.3", "@vue/test-utils": "^2.5.1", "eslint": "^10.9.0", "h3": "^1.15.11", @@ -3110,28 +3110,28 @@ } }, "node_modules/@nuxt/test-utils": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-4.3.4.tgz", - "integrity": "sha512-8x0w16DDUi1/6ZivL1zuOaDt9xtACZFHMQySBirUZNIU0H0qljjB5yyajXPtlb8YCQ5l6piFJqafK/zDpDcCnA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-4.3.3.tgz", + "integrity": "sha512-0Nxp6e+VJROAMGuyurTX3+ON+l3y79TuNDsciy+7TfCPx4GcitTrYsxjomi65jLVi0H32aEb2sloh/qmiwj6aA==", "dev": true, "license": "MIT", "dependencies": { "@clack/prompts": "1.8.1", "@nuxt/devtools-kit": "^3.4.2", - "@nuxt/kit": "^4.6.1", + "@nuxt/kit": "^4.5.2", "consola": "^3.4.2", "defu": "^6.1.7", - "exsolve": "^1.1.3", + "exsolve": "^1.1.1", "fake-indexeddb": "^6.2.5", "get-port-please": "^3.2.0", "magic-string": "^1.4.2", "ofetch": "^1.5.1", "oxc-walker": "^1.1.1", - "package-manager-detector": "^1.9.0", + "package-manager-detector": "^1.8.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "radix3": "^1.1.2", - "std-env": "^4.3.0", + "std-env": "^4.2.0", "tinyexec": "^1.3.1", "ufo": "^1.6.4", "unplugin": "^3.4.0", diff --git a/clients/dashboard/package.json b/clients/dashboard/package.json index 913e30c..caf5576 100644 --- a/clients/dashboard/package.json +++ b/clients/dashboard/package.json @@ -24,7 +24,7 @@ }, "devDependencies": { "@nuxt/eslint": "^1.17.0", - "@nuxt/test-utils": "^4.3.4", + "@nuxt/test-utils": "^4.3.3", "@vue/test-utils": "^2.5.1", "eslint": "^10.9.0", "h3": "^1.15.11", From c96982888a03359592d8d07661b88f489e044683 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Mon, 26 Oct 2026 16:32:37 +0300 Subject: [PATCH 06/44] Take the test-utils bump back out of the bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3f96936 reverted @nuxt/test-utils 4.3.4 → 4.3.3 on the grounds that 4.3.3 was fine and a stale node_modules had been the real problem. It was not fine, and this puts the bump back. What both wrong accounts have in common is that every measurement of 4.3.3 was taken on a **cold vitest cache**. `node_modules/.vitest-cache` goes with the install, so the run straight after `npm install` or `npm ci` is always the first one — and that run passes. The second does not: 4.3.3 run 1: 8 passed run 2: FAILS run 3: FAILS 4.3.4 run 1: 8 passed run 2: 8 passed run 3: 8 passed Measured just now in two scratch trees with identical source, each installed with `npm ci` from its own lockfile, three runs apiece. The failure is the same one as before — every suite fails to load with "Failed to resolve import ./diagnostics/core.js" — and `vitest run --no-cache` does not avoid it, because that flag does not discard the cache that is already there. Deleting `node_modules/.vitest-cache` by hand makes exactly the next run pass. So the original diagnosis was right by accident and the correction was wrong on purpose. Both of them read "it passed after I reinstalled" as "the reinstall fixed it", when the reinstall had only emptied the cache. CI never saw any of this: `npm ci` on a fresh runner runs the suite once. Lint, typecheck and three consecutive `npm test` runs pass on 4.3.4. Co-Authored-By: Claude Opus 5 --- clients/dashboard/.nuxtrc | 2 +- clients/dashboard/package-lock.json | 16 ++++++++-------- clients/dashboard/package.json | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/clients/dashboard/.nuxtrc b/clients/dashboard/.nuxtrc index 6415c6e..472fb3f 100644 --- a/clients/dashboard/.nuxtrc +++ b/clients/dashboard/.nuxtrc @@ -1 +1 @@ -setups.@nuxt/test-utils="4.3.3" \ No newline at end of file +setups.@nuxt/test-utils="4.3.4" \ No newline at end of file diff --git a/clients/dashboard/package-lock.json b/clients/dashboard/package-lock.json index b3e249c..5e211df 100644 --- a/clients/dashboard/package-lock.json +++ b/clients/dashboard/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@nuxt/eslint": "^1.17.0", - "@nuxt/test-utils": "^4.3.3", + "@nuxt/test-utils": "^4.3.4", "@vue/test-utils": "^2.5.1", "eslint": "^10.9.0", "h3": "^1.15.11", @@ -3110,28 +3110,28 @@ } }, "node_modules/@nuxt/test-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-4.3.3.tgz", - "integrity": "sha512-0Nxp6e+VJROAMGuyurTX3+ON+l3y79TuNDsciy+7TfCPx4GcitTrYsxjomi65jLVi0H32aEb2sloh/qmiwj6aA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@nuxt/test-utils/-/test-utils-4.3.4.tgz", + "integrity": "sha512-8x0w16DDUi1/6ZivL1zuOaDt9xtACZFHMQySBirUZNIU0H0qljjB5yyajXPtlb8YCQ5l6piFJqafK/zDpDcCnA==", "dev": true, "license": "MIT", "dependencies": { "@clack/prompts": "1.8.1", "@nuxt/devtools-kit": "^3.4.2", - "@nuxt/kit": "^4.5.2", + "@nuxt/kit": "^4.6.1", "consola": "^3.4.2", "defu": "^6.1.7", - "exsolve": "^1.1.1", + "exsolve": "^1.1.3", "fake-indexeddb": "^6.2.5", "get-port-please": "^3.2.0", "magic-string": "^1.4.2", "ofetch": "^1.5.1", "oxc-walker": "^1.1.1", - "package-manager-detector": "^1.8.0", + "package-manager-detector": "^1.9.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "radix3": "^1.1.2", - "std-env": "^4.2.0", + "std-env": "^4.3.0", "tinyexec": "^1.3.1", "ufo": "^1.6.4", "unplugin": "^3.4.0", diff --git a/clients/dashboard/package.json b/clients/dashboard/package.json index caf5576..913e30c 100644 --- a/clients/dashboard/package.json +++ b/clients/dashboard/package.json @@ -24,7 +24,7 @@ }, "devDependencies": { "@nuxt/eslint": "^1.17.0", - "@nuxt/test-utils": "^4.3.3", + "@nuxt/test-utils": "^4.3.4", "@vue/test-utils": "^2.5.1", "eslint": "^10.9.0", "h3": "^1.15.11", From 6ade1531abe22b4507149af30ff41be043017423 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Mon, 26 Oct 2026 17:32:02 +0300 Subject: [PATCH 07/44] Sign every request in as one sample user, and drop the "acting as" box The dashboard asked who you were before the Audit page would load, and without a name every catalogue change was recorded against nobody. The API's stand-in scheme now signs a request that names nobody in as `sample.user@example.com`, so the dashboard needs no control for it. `X-Sample-Actor` is still honoured when it is sent, which is how the integration tests act as alice, bob and carol. The audit endpoints still require an authenticated caller, the library's default; the scheme simply always provides one. Removed from the dashboard: ActingAs.vue, its plugin and localStorage helper, `useActor`, the header `useApi` attached, and the 401 "needs a caller" alert with its translations. The test that pinned the anonymous 401 now pins the default user instead. Co-Authored-By: Claude Opus 5.5 --- README.md | 31 ++--- clients/dashboard/app/components/ActingAs.vue | 108 ------------------ clients/dashboard/app/composables/useApi.ts | 45 ++------ .../app/composables/useAuditTrail.ts | 33 +----- clients/dashboard/app/layouts/default.vue | 7 -- clients/dashboard/app/pages/audit.vue | 23 +--- clients/dashboard/app/plugins/acting-as.ts | 26 ----- .../dashboard/app/utils/rememberedActor.ts | 39 ------- clients/dashboard/i18n/locales/ar.json | 17 --- clients/dashboard/i18n/locales/en.json | 17 --- clients/dashboard/i18n/locales/fr.json | 17 --- clients/dashboard/server/api/[...path].ts | 30 +---- .../dashboard/test/nuxt/useAuditTrail.spec.ts | 59 ++-------- docs/screenshots/README.md | 7 +- .../SampleActorAuthentication.cs | 17 ++- .../Integration/AuditTrailTests.cs | 28 ++--- 16 files changed, 61 insertions(+), 443 deletions(-) delete mode 100644 clients/dashboard/app/components/ActingAs.vue delete mode 100644 clients/dashboard/app/plugins/acting-as.ts delete mode 100644 clients/dashboard/app/utils/rememberedActor.ts diff --git a/README.md b/README.md index ca8e136..c14ea97 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,7 @@ because they are the point. meet](docs/screenshots/overview.png) A sidebar shell matching the four libraries' own dashboards — **Catalog**, **Media**, -**Languages**, **Audit**, **Settings**, with the reading-language selector in every page header -and, at the foot of the sidebar, who the dashboard is **acting as**. +**Languages**, **Audit**, **Settings**, with the reading-language selector in every page header. That one selector moves **both** languages in the dashboard. The content language is a row in DotNetBoost.MultiLingual that the API resolves from `Accept-Language`; the dashboard's own labels, @@ -183,19 +182,12 @@ with](#what-a-catalogue-write-answers-with) has the shapes. ![The audit trail: one entity's entries, and what the model says is recorded for it](docs/screenshots/audit.png) -**Audit** is DotNetBoost.Auditing's trail, and the one page here that has to be signed in to. -`MapAuditEndpoints()` requires an authenticated caller *by default* — a trail says who did what and -when — and this sample keeps that default rather than switching it off, so with nobody set the page -explains the `401` instead of showing an empty table. - -Who you are is the control at the foot of the sidebar: **acting as**. It is a name you type, sent as -`X-Sample-Actor` on every call the dashboard makes — which is what makes your own catalogue writes -attributable as well as the trail readable. The dashboard knows nobody, so nothing is invented -there: acting as nobody is the starting state, changes made that way are recorded with no actor, and -the trail renders that as `unknown` rather than naming a person who does not exist. The API trusts -the header outright and [says so at -length](src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs); the dashboard's half -of the bargain is to send only a name somebody typed. +**Audit** is DotNetBoost.Auditing's trail. `MapAuditEndpoints()` requires an authenticated caller +*by default* — a trail says who did what and when — and this sample keeps that default rather than +switching it off. The API's [stand-in +scheme](src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs) signs every request +that names nobody in as one sample user, `sample.user@example.com`, so the dashboard has no sign-in +control: the trail is always readable and your catalogue writes are attributed to that user. The filters — entity, key, actor, action and a date range — are parameters on the query the API is asked, never a pass over the rows already on screen, which for an append-only trail would search the @@ -275,9 +267,8 @@ curl -H 'X-Sample-Actor: alice@example.com' \ 'http://localhost:5180/api/audit?entityName=Product&take=10' ``` -The dashboard sends the same header, from the **acting as** control at the foot of its sidebar — so -a product changed there is attributed to whatever name is set, and to no actor at all while none is. -Either way the trail is read on the Audit page as well as with `curl`. +Without the header the request is signed in as `sample.user@example.com` — which is how the +dashboard calls the API, so a product changed there is attributed to that user. **The dashboard** needs **Node 22+**, its dependencies installed — `aspire run` does that for you and nothing else does — and the address of that API: @@ -1039,9 +1030,7 @@ reads as "no entries match" for a filter that matches plenty. Two more propertie it: that `skip` is never sent at all, which the API refuses together with `after` anyway; and that a filter typed while the first page is still in flight is not answered with that page — the entry is registered `dedupe: 'defer'` for the readers that arrive in the same tick, so every deliberate move -refreshes with `dedupe: 'cancel'` instead. The fourth test is the one the whole page hangs off: -without the actor header the trail is a `401`, and naming somebody makes it readable — a header that -never left the browser looks exactly like a server that is down. +refreshes with `dedupe: 'cancel'` instead. The specs live under `test/nuxt/` because Nuxt's generated tsconfig includes that path, so `npm run typecheck` covers them. Anywhere else they run and are silently not typechecked — which diff --git a/clients/dashboard/app/components/ActingAs.vue b/clients/dashboard/app/components/ActingAs.vue deleted file mode 100644 index 0ff2d66..0000000 --- a/clients/dashboard/app/components/ActingAs.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - diff --git a/clients/dashboard/app/composables/useApi.ts b/clients/dashboard/app/composables/useApi.ts index 0425520..ea1b439 100644 --- a/clients/dashboard/app/composables/useApi.ts +++ b/clients/dashboard/app/composables/useApi.ts @@ -10,9 +10,7 @@ import type { * The media library is NOT wrapped here: `useMediaLibrary` talks to `/api/media` directly, exactly * as the DotNetBoost.Media dashboard does, so searching, filtering, sorting, paging, uploading, * attaching and detaching are that library's own behaviour rather than a second implementation. - * Those calls therefore carry neither header, which costs nothing: a media file is not an audited - * entity here — `GET api/audit/entities` lists this application's two — so there is no entry for - * an actor to be missing from. + * Those calls therefore carry no Accept-Language, which costs nothing here. */ /** @@ -40,30 +38,6 @@ const CAPTION = 'Caption' */ export const useLanguage = () => useState('language', () => 'en') -/** - * Who the dashboard is acting as. Sent as `X-Sample-Actor` on every call that goes through here, - * and empty for "nobody", which is a request with no such header at all. - * - * Two things in this sample depend on it, and they are the two halves of what DotNetBoost.Auditing - * does: - * - * • **the audit endpoints require an authenticated caller** — the library's own default, which - * this sample deliberately keeps — so with no name the Audit page gets a `401` and says why; - * • **a change records whoever made it**. `UseHttpContextActor()` reads `HttpContext.User`, so a - * catalogue write that carries this header is attributed to that name, and one that does not - * is recorded with no actor rather than with a guess. - * - * **This is not authentication and must not be read as any.** The API's scheme trusts the header - * outright — anyone who can reach it can claim to be anyone — and - * `Authentication/SampleActorAuthentication.cs` is where that is set out in full. The dashboard's - * side of the bargain is to send only a name a person typed into `ActingAs.vue`: no default, no - * `dashboard@localhost`, nothing invented here and presented to the trail as an identity. - * - * Seeded empty and overwritten by `plugins/acting-as.ts` with whatever was last used, before the - * app mounts. - */ -export const useActor = () => useState('actor', () => '') - /** * MultiLingual's translation route for a subject, from the two strings the API published for it. * Encoded, because an entity name is a value and not a path fragment this client chose. @@ -87,21 +61,16 @@ export interface Abortable { export function useApi() { const language = useLanguage() - const actor = useActor() /** - * The two headers every call below carries: the language to answer in, and who is asking. - * - * The actor is omitted rather than sent empty when nobody is set. A `X-Sample-Actor:` with no - * value is not an anonymous request to a header-trusting scheme — it is a request to be - * authenticated as the empty string — and the API's handler is written to read it as anonymous - * either way, which is exactly the kind of agreement that should not need to hold. + * The header every call below carries: the language to answer in. It travels through the Nitro + * proxy untouched; `server/api/[...path].ts` says why. * - * Both travel through the Nitro proxy untouched; `server/api/[...path].ts` says why. + * No actor: the API signs every request in as its one sample user — see + * `Authentication/SampleActorAuthentication.cs`. */ const headers = (): Record => ({ - 'Accept-Language': language.value, - ...(actor.value ? { 'X-Sample-Actor': actor.value } : {}) + 'Accept-Language': language.value }) return { @@ -143,7 +112,7 @@ export function useApi() { // // Read-only, because this application maps only the library's read endpoints: no erasure, no // prune. Every one of them requires an authenticated caller — the library's own default, kept — - // so all four go out with the actor header `headers()` adds, and answer `401` without it. + // which the API's sample scheme always provides. audit: { /** * One page of the trail, newest first. diff --git a/clients/dashboard/app/composables/useAuditTrail.ts b/clients/dashboard/app/composables/useAuditTrail.ts index 4cf07df..39fa388 100644 --- a/clients/dashboard/app/composables/useAuditTrail.ts +++ b/clients/dashboard/app/composables/useAuditTrail.ts @@ -75,8 +75,7 @@ export function useAuditEntities() { */ export function useAuditTrail() { const api = useApi() - const actor = useActor() - const { entities, maxPageSize, error: entitiesError, refresh: refreshEntities } = useAuditEntities() + const { entities, maxPageSize } = useAuditEntities() const query = reactive({ entityName: '', @@ -175,19 +174,8 @@ export function useAuditTrail() { query.entityName || query.entityId || query.actor || query.action || query.from || query.to || query.correlationId)) - /** - * Whether the API refused the read for want of a caller. - * - * The one endpoint set in this application that does — DotNetBoost.Auditing's own default, which - * the sample keeps — so a `401` here is not a fault to report as one. The page explains it and - * points at the control that fixes it. - */ - const unauthorized = computed(() => - statusOf(error.value) === 401 || statusOf(entitiesError.value) === 401) - - /** Any other refusal, in the API's own words. Empty while the trail is merely unauthorized. */ - const loadError = computed(() => - !unauthorized.value && error.value ? problemMessage(error.value) : '') + /** A refusal, in the API's own words. */ + const loadError = computed(() => error.value ? problemMessage(error.value) : '') /** Reloads the listing for a question that has just changed. Never deferred — see above. */ const reload = () => refresh({ dedupe: 'cancel' }) @@ -279,25 +267,12 @@ export function useAuditTrail() { }) } - /** - * Setting a name in the sidebar makes the trail readable; clearing it makes it a `401` again. - * - * One watcher, here, rather than one in `useActor` — which every call in `useApi` reads, so a - * watcher living there would be one per caller all refreshing the same keys. This is the only - * screen whose answers change with who is asking. - */ - watch(actor, () => { - refreshEntities({ dedupe: 'cancel' }) - schemaData.refresh({ dedupe: 'cancel' }) - reload() - }) - /** The rules follow the entity filter, and nothing else re-reads them. */ watch(() => query.entityName, () => schemaData.refresh({ dedupe: 'cancel' })) return { query, entries, entities, maxPageSize, schema: schemaData.data, - pending, page, hasNewer, hasOlder, isFiltered, unauthorized, loadError, + pending, page, hasNewer, hasOlder, isFiltered, loadError, setFilters, setPageSize, older, newer, showEdit, reset, reload } } diff --git a/clients/dashboard/app/layouts/default.vue b/clients/dashboard/app/layouts/default.vue index c3eeb0c..85e09a7 100644 --- a/clients/dashboard/app/layouts/default.vue +++ b/clients/dashboard/app/layouts/default.vue @@ -93,13 +93,6 @@ const groups = computed(() => [{ diff --git a/clients/dashboard/app/pages/audit.vue b/clients/dashboard/app/pages/audit.vue index 1b5af5f..adde936 100644 --- a/clients/dashboard/app/pages/audit.vue +++ b/clients/dashboard/app/pages/audit.vue @@ -10,9 +10,8 @@ import type { AuditAction, AuditEntry } from '~/types' * * • **it needs a caller.** `MapAuditEndpoints()` requires an authenticated one by default — a * trail says who did what and when, which is personal data about an organisation's staff — and - * the sample keeps that default instead of turning it off. With nobody set in the sidebar the - * API answers `401` and this page says so, which is a more honest first screen than an empty - * table; + * the sample keeps that default instead of turning it off. The API's sample scheme signs every + * request in as one sample user, so the page is always readable here; * • **translated text is not in it.** Name and Description carry `[AuditIgnore]`, so a save that * only changed Arabic text adds no entry. The rules card says that in the model's own words, * next to the trail, because the alternative is someone concluding the recording is broken. @@ -23,7 +22,7 @@ useHead({ title: () => t('nav.documentTitle', { page: t('nav.audit') }) }) const { query, entries, entities, maxPageSize, schema, - pending, page, hasNewer, hasOlder, isFiltered, unauthorized, loadError, + pending, page, hasNewer, hasOlder, isFiltered, loadError, setFilters, setPageSize, older, newer, showEdit, reset, reload } = useAuditTrail() @@ -117,22 +116,8 @@ async function go(move: () => unknown) { /> - - - rememberActor(value.trim())) - } -}) diff --git a/clients/dashboard/app/utils/rememberedActor.ts b/clients/dashboard/app/utils/rememberedActor.ts deleted file mode 100644 index 3c2a6de..0000000 --- a/clients/dashboard/app/utils/rememberedActor.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Who the dashboard was last acting as, across visits. - * - * `localStorage`, beside the reading language and for the same reason: nothing on the server wants - * it. `X-Sample-Actor` is attached per request by `useApi` from the live value — a cookie would be - * a second copy for the Nitro proxy to read and disagree with. - * - * **It is not a credential, and remembering it is not "staying signed in".** The API's scheme - * trusts the header outright; see `Authentication/SampleActorAuthentication.cs`, which says so at - * length. What is kept here is the answer to "whose name should the trail carry", so that reloading - * the page does not quietly go back to recording changes against nobody. - */ -const STORAGE_KEY = 'dotnetboost-sample:acting-as' - -/** - * Guarded like {@link readRememberedLanguage}: a private window, blocked site data and a cleared - * profile each throw or answer `null`, and none of them is a reason for the dashboard to refuse to - * open. It starts out acting as nobody, which is a state the whole app already handles. - */ -export function readRememberedActor(): string { - try { - return localStorage.getItem(STORAGE_KEY)?.trim() ?? '' - } catch { - return '' - } -} - -/** Stores the name, or forgets it when it is empty — "acting as nobody" is not a name to keep. */ -export function rememberActor(actor: string): void { - try { - if (actor) { - localStorage.setItem(STORAGE_KEY, actor) - } else { - localStorage.removeItem(STORAGE_KEY) - } - } catch { - // The choice still holds for this session; it just will not outlive it. - } -} diff --git a/clients/dashboard/i18n/locales/ar.json b/clients/dashboard/i18n/locales/ar.json index f023d6d..699bf26 100644 --- a/clients/dashboard/i18n/locales/ar.json +++ b/clients/dashboard/i18n/locales/ar.json @@ -37,19 +37,6 @@ "readingLanguage": { "label": "لغة القراءة" }, - "actingAs": { - "trigger": "العمل باسم", - "nobody": "دون اسم", - "label": "الاسم", - "placeholder": "alice{'@'}example.com", - "help": "يُرسَل في ترويسة X-Sample-Actor مع كل طلب. يسجّله سجل التدقيق مع كل تغيير تجريه، وقراءة السجل تحتاجه.", - "apply": "تطبيق", - "clear": "العمل دون اسم", - "notAuthentication": { - "title": "ليست مصادقة", - "description": "تثق واجهة البرمجة بهذه الترويسة كما هي — ومن يصل إليها يستطيع أن يدّعي أي اسم. هي تنوب عن تسجيل الدخول حتى يتمكّن هذا المثال من إظهار سجل تدقيق بأسماء حقيقية." - } - }, "appearance": { "trigger": "المظهر", "theme": "السمة", @@ -279,10 +266,6 @@ "redactedTooltip": "يصف النموذج هذه الخاصية بأنها حسّاسة، فحُجبت القيم ولم تُسجَّل. هي ليست مفقودة — بل لم تُخزَّن قطّ.", "truncated": "مقتطعة", "truncatedTooltip": "كانت القيمة أطول من الحد المضبوط فاقتُطعت عند تسجيلها.", - "needsCaller": { - "title": "قراءة السجل تحتاج طالبًا موثَّقًا", - "description": "تتطلّب نقاط نهاية DotNetBoost.Auditing طالبًا موثَّقًا، وهذا المثال يحتفظ بهذا الإعداد الافتراضي ولا يعطّله — فالسجل يقول من فعل ماذا ومتى. اضبط اسمًا من «العمل باسم» في أسفل الشريط الجانبي؛ يُرسَل مع كل طلب، وتُسجَّل تغييراتك باسمه من تلك اللحظة. وحتى ذلك الحين تُسجَّل التغييرات التي تجري هنا بلا فاعل." - }, "filters": { "entity": "الكيان", "anyEntity": "أي كيان", diff --git a/clients/dashboard/i18n/locales/en.json b/clients/dashboard/i18n/locales/en.json index 1c9b3b6..6fbbf01 100644 --- a/clients/dashboard/i18n/locales/en.json +++ b/clients/dashboard/i18n/locales/en.json @@ -37,19 +37,6 @@ "readingLanguage": { "label": "Reading language" }, - "actingAs": { - "trigger": "Acting as", - "nobody": "Acting as nobody", - "label": "Name", - "placeholder": "alice{'@'}example.com", - "help": "Sent as X-Sample-Actor on every call. The audit trail records it against each change you make, and reading the trail needs it.", - "apply": "Apply", - "clear": "Act as nobody", - "notAuthentication": { - "title": "Not authentication", - "description": "The API trusts this header outright — anyone who can reach it can claim to be anyone. It stands in for a sign-in so the sample can show a trail with real names in it." - } - }, "appearance": { "trigger": "Appearance", "theme": "Theme", @@ -279,10 +266,6 @@ "redactedTooltip": "The model marks this property sensitive, so the values were withheld rather than recorded. They are not missing — they were never stored.", "truncated": "truncated", "truncatedTooltip": "A value was longer than the configured limit and was cut short when it was recorded.", - "needsCaller": { - "title": "Reading the trail needs a caller", - "description": "DotNetBoost.Auditing's endpoints require an authenticated caller, and this sample keeps that default rather than turning it off — a trail says who did what and when. Set a name under \"Acting as\" at the bottom of the sidebar; it is sent on every call, and your own changes are recorded against it from then on. Until then, changes made here are recorded with no actor at all." - }, "filters": { "entity": "Entity", "anyEntity": "Any entity", diff --git a/clients/dashboard/i18n/locales/fr.json b/clients/dashboard/i18n/locales/fr.json index 7525979..153b0a0 100644 --- a/clients/dashboard/i18n/locales/fr.json +++ b/clients/dashboard/i18n/locales/fr.json @@ -37,19 +37,6 @@ "readingLanguage": { "label": "Langue de lecture" }, - "actingAs": { - "trigger": "Agir en tant que", - "nobody": "Aucun nom", - "label": "Nom", - "placeholder": "alice{'@'}example.com", - "help": "Envoyé dans l'en-tête X-Sample-Actor à chaque appel. Le journal d'audit l'enregistre avec chacune de vos modifications, et sa lecture l'exige.", - "apply": "Appliquer", - "clear": "N'agir sous aucun nom", - "notAuthentication": { - "title": "Ce n'est pas une authentification", - "description": "L'API fait confiance à cet en-tête tel quel — quiconque peut l'atteindre peut se prétendre n'importe qui. Il tient lieu de connexion pour que cet exemple puisse montrer un journal portant de vrais noms." - } - }, "appearance": { "trigger": "Apparence", "theme": "Thème", @@ -279,10 +266,6 @@ "redactedTooltip": "Le modèle marque cette propriété comme sensible : les valeurs ont été retenues au lieu d'être enregistrées. Elles ne manquent pas — elles n'ont jamais été stockées.", "truncated": "tronquée", "truncatedTooltip": "Une valeur dépassait la limite configurée et a été coupée à l'enregistrement.", - "needsCaller": { - "title": "La lecture du journal exige un appelant", - "description": "Les points de terminaison de DotNetBoost.Auditing exigent un appelant authentifié, et cet exemple conserve ce comportement par défaut au lieu de le désactiver — un journal dit qui a fait quoi et quand. Définissez un nom sous « Agir en tant que », en bas de la barre latérale ; il est envoyé à chaque appel, et vos modifications sont dès lors enregistrées sous ce nom. D'ici là, les modifications faites ici sont enregistrées sans aucun acteur." - }, "filters": { "entity": "Entité", "anyEntity": "Toute entité", diff --git a/clients/dashboard/server/api/[...path].ts b/clients/dashboard/server/api/[...path].ts index 83f9c24..7e74a39 100644 --- a/clients/dashboard/server/api/[...path].ts +++ b/clients/dashboard/server/api/[...path].ts @@ -8,32 +8,10 @@ * * ## Who the API thinks is calling * - * DotNetBoost.Auditing's endpoints require an authenticated caller — the library's own default, - * which this sample keeps — and the API's stand-in scheme reads the caller's name off - * `X-Sample-Actor`. So something had to decide what the browser sends. There were two honest - * answers, and this one is the second: - * - * • **the proxy could add the header itself**, forwarding one name the dashboard knows about. It - * is one line here and it is rejected: the dashboard does not know about anybody. The name - * would have to be made up — `dashboard@localhost`, say — and every entry in the trail would - * then carry an identity nobody has, which is worse than carrying none. It would also attribute - * every visitor's changes to the same invented person, which is the one thing an audit trail - * must not do; - * • **the dashboard asks who you are** and sends that. `ActingAs.vue` in the sidebar is the whole - * of it, `useApi` attaches the answer to every call, and acting as nobody is both the starting - * state and a state the UI handles: catalogue writes are then recorded with no actor — honestly - * — and the Audit page explains the `401` it gets instead of pretending the trail is empty. - * - * **Nothing is added, removed or relaxed here.** `proxyRequest` forwards the request's headers as - * they arrived, minus the hop-by-hop ones h3 strips (`connection`, `host`, `accept-encoding` and - * friends), so `X-Sample-Actor` and `Accept-Language` reach the API exactly as the browser sent - * them — which is also how the reading language has always travelled. A proxy that rewrote either - * would be a second opinion about who is calling, in the one place nobody would think to look for - * one. - * - * That the header is a poor authenticator is the API's business and the API says so at length, in - * `src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs`. What matters on this - * side is that the dashboard never claims to be somebody on a person's behalf. + * Nobody the dashboard chooses. The API's stand-in scheme signs every request in as one sample + * user — see `src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs` — so nothing + * here adds an identity header. `proxyRequest` forwards the request's headers as they arrived, + * minus the hop-by-hop ones h3 strips, which is how `Accept-Language` reaches the API. */ export default defineEventHandler(async (event) => { const { apiUrl } = useRuntimeConfig(event) diff --git a/clients/dashboard/test/nuxt/useAuditTrail.spec.ts b/clients/dashboard/test/nuxt/useAuditTrail.spec.ts index 36ca402..e624035 100644 --- a/clients/dashboard/test/nuxt/useAuditTrail.spec.ts +++ b/clients/dashboard/test/nuxt/useAuditTrail.spec.ts @@ -1,6 +1,6 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' import { enableAutoUnmount } from '@vue/test-utils' -import { createError, defineEventHandler, getHeader, getQuery } from 'h3' +import { createError, defineEventHandler, getQuery } from 'h3' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h } from 'vue' import type { AuditEntry } from '~/types' @@ -8,7 +8,7 @@ import type { AuditEntry } from '~/types' /** * How the Audit page asks the trail its question, and what happens when the question changes. * - * Why this file exists. Three of the four properties below were wrong in a first cut of + * Why this file exists. The three properties below were all wrong in a first cut of * `useAuditTrail`, and none of them is visible from inside one request: * *
    @@ -28,10 +28,6 @@ import type { AuditEntry } from '~/types' * once for the page bound — which is the bug `useLanguageList.spec.ts` documents at length, * arriving a second time on a second endpoint. *
- * - * The fourth is the one this whole page hangs off: the actor header has to actually be sent. - * The audit endpoints are the only ones in this application that refuse an anonymous caller, so a - * header that never left the browser looks exactly like a server that is down. */ /** The trail the fake endpoint serves, newest first — the order the API answers in. */ @@ -82,18 +78,13 @@ let entityReads: number let gate: Promise | null /** - * `GET api/audit`, close enough to the real one where it matters: it refuses an anonymous caller, - * it filters by actor, it pages by cursor, and it refuses `skip` and `after` together the way the + * `GET api/audit`, close enough to the real one where it matters: it filters by actor, it pages by cursor, and it refuses `skip` and `after` together the way the * API does — which is what makes "never send `skip`" an assertion rather than a convention. * * The cursor is the last entry's id. The real tokens are opaque, and nothing in the composable may * read one, which is exactly why a stand-in can stand in. */ registerEndpoint('/api/audit', defineEventHandler(async (event) => { - if (!getHeader(event, 'x-sample-actor')) { - throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) - } - const query = getQuery(event) as Record reads.push(query) @@ -127,11 +118,7 @@ registerEndpoint('/api/audit', defineEventHandler(async (event) => { } })) -registerEndpoint('/api/audit/entities', defineEventHandler((event) => { - if (!getHeader(event, 'x-sample-actor')) { - throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) - } - +registerEndpoint('/api/audit/entities', defineEventHandler(() => { entityReads++ return { @@ -143,25 +130,19 @@ registerEndpoint('/api/audit/entities', defineEventHandler((event) => { } })) -/** - * Mounts something that reads the trail, acting as `actor` — set before the composable runs, so the - * first request carries it, exactly as `plugins/acting-as.ts` arranges in the app. - */ -async function mountTrail(actor: string) { +/** Mounts something that reads the trail. */ +async function mountTrail() { let trail!: ReturnType - let acting!: Ref await mountSuspended(defineComponent({ setup() { - acting = useActor() - acting.value = actor trail = useAuditTrail() return () => h('div') } })) - return { trail, acting } + return { trail } } /** A second reader of the declared entities, the way the filter bar is one. */ @@ -209,28 +190,8 @@ beforeEach(async () => { }) describe('useAuditTrail', () => { - it('refuses to read without a caller, and reads as soon as there is one', async () => { - const { trail, acting } = await mountTrail('') - - // DotNetBoost.Auditing's own default, which this sample keeps: the one endpoint set in the - // application that an anonymous caller cannot read. Not a fault to report as one. - await vi.waitFor(() => expect(trail.unauthorized.value).toBe(true)) - expect(trail.loadError.value).toBe('') - expect(trail.entries.value).toEqual([]) - - // Naming somebody in the sidebar is the whole of the fix, and the trail re-reads itself. - acting.value = 'carol@example.com' - - await vi.waitFor(() => expect(trail.entries.value).toHaveLength(3)) - expect(trail.unauthorized.value).toBe(false) - - // The header travelled. It is attached by `useApi` and forwarded untouched by the Nitro - // proxy; nothing in between invents a name or drops one. - expect(reads).toHaveLength(1) - }) - it('reads the declared entities once for every reader, and takes the page bound from them', async () => { - const { trail } = await mountTrail('carol@example.com') + const { trail } = await mountTrail() await vi.waitFor(() => expect(trail.entries.value).toHaveLength(3)) @@ -248,7 +209,7 @@ describe('useAuditTrail', () => { }) it('walks by cursor, and starts the walk again whenever the question changes', async () => { - const { trail } = await mountTrail('carol@example.com') + const { trail } = await mountTrail() await vi.waitFor(() => expect(trail.entries.value).toHaveLength(3)) @@ -308,7 +269,7 @@ describe('useAuditTrail', () => { // The first page is still out: nothing has resolved, and the entry is registered with // `dedupe: 'defer'`. - const { trail } = await mountTrail('carol@example.com') + const { trail } = await mountTrail() await vi.waitFor(() => expect(reads).toHaveLength(1)) diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 4010ec1..a9725da 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -53,7 +53,7 @@ curl -s -o /dev/null -X POST "$API/api/products" -H 'Content-Type: application/j "fr": { "Name": "Bibliothèque en frêne", "Description": "Cinq étagères, dos ouvert." } }}' -# 2. Somebody with no name corrects its SKU — no header, so no actor is recorded. +# 2. The default sample user corrects its SKU — no header, so it is recorded as sample.user@example.com. repost "" 4 '{"sku": "SHELF-002"}' # 3. carol withdraws the lamp. Its history outlives the row. @@ -84,10 +84,7 @@ repost alice@example.com 1 '{"price": 389.95}' The fifth is the one worth keeping if these are ever rewritten: it is a write that adds **no entry**, which is exactly what the rules card beside the entries explains. -The page is then photographed with the entity filter on `Product`, the newest entry opened, and the -dashboard acting as `alice@example.com` — that name lives in `localStorage` under -`dotnetboost-sample:acting-as`, where the sidebar control keeps it, so it can be set before the -first paint rather than clicked in. +The page is then photographed with the entity filter on `Product` and the newest entry opened. It is **taller than the others**, because the rules card and an opened entry do not fit in their height. 1400px wide at 2× is the part that is fixed; the height is whatever the page needs with diff --git a/src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs b/src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs index 9e2f09b..5c5f591 100644 --- a/src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs +++ b/src/DotNetBoostSample.Api/Authentication/SampleActorAuthentication.cs @@ -31,11 +31,10 @@ namespace DotNetBoostSample.Api.Authentication; /// changed it" demonstrates half a library. A header is a poor authenticator and a perfectly good /// actor, and the actor is the part this sample is showing. /// -/// Everything else stays anonymous. Only the audit endpoints require a caller, because only -/// they ask for one; the catalogue, the settings, the media and the languages are reachable as -/// before. What this scheme adds to those is attribution: a write that arrives with the header -/// gets recorded against that name, and one that does not is recorded with no name rather than -/// with a guess. +/// Every request is signed in. A request without the header is signed in as +/// , so the dashboard needs no "who are you" control: the trail is +/// readable and every change is attributed to the one sample user. The header is still honoured +/// when present, which is how the integration tests act as more than one person. ///
public sealed class SampleActorAuthenticationHandler( IOptionsMonitor options, @@ -54,14 +53,14 @@ public sealed class SampleActorAuthenticationHandler( ///
public const string ActorHeader = "X-Sample-Actor"; + /// Who a request is signed in as when it does not name anyone. + public const string DefaultActor = "sample.user@example.com"; + protected override Task HandleAuthenticateAsync() { var actor = Request.Headers[ActorHeader].ToString().Trim(); - // NoResult rather than Fail: no header is an anonymous request, which is allowed - // everywhere except the audit endpoints. Fail would turn every unauthenticated read of - // the catalogue into a 401. - if (string.IsNullOrEmpty(actor)) return Task.FromResult(AuthenticateResult.NoResult()); + if (string.IsNullOrEmpty(actor)) actor = DefaultActor; // ClaimTypes.Name, because that is what IIdentity.Name reads and what the library's // HttpContextAuditActorAccessor therefore records. The engine caps the stored actor at diff --git a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs index fd947de..3e3e6e9 100644 --- a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs @@ -1,3 +1,4 @@ +using DotNetBoostSample.Api.Authentication; using System.Net; using System.Text.Json; using Xunit; @@ -214,30 +215,25 @@ public async Task A_refused_write_records_nothing() } /// - /// The trail is not readable without a caller — the one endpoint set in this application that - /// requires one. + /// A request that names nobody is signed in as the sample's one default user — so the trail is + /// readable without the header and a change made that way is attributed to that user. /// - /// That is DotNetBoost.Auditing's own default and the reason this sample has an - /// authentication scheme at all. The three sibling libraries map their endpoints anonymously - /// and leave securing them to the application; this one does not, because a trail says who did - /// what and when. Pinned here so that "we turned it off to get the tests passing" would be a - /// failing test rather than a quiet edit. + /// The audit endpoints still require an authenticated caller — DotNetBoost.Auditing's own + /// default, kept — the sample scheme simply always provides one. /// [RequiresDockerFact] - public async Task The_trail_is_the_one_thing_here_that_refuses_an_anonymous_caller() + public async Task A_caller_who_names_nobody_is_the_default_sample_user() { // No X-Sample-Actor header. - var anonymous = app.Client; + var client = app.Client; - Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/audit")).StatusCode); - Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/audit/entities")).StatusCode); + var id = await client.CreateProductAsync(app.SeededCategoryId, "Made by the default user"); - // While everything else is reachable exactly as before. - Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync("/api/products")).StatusCode); - Assert.Equal(HttpStatusCode.OK, (await anonymous.GetAsync("/api/settings")).StatusCode); + var entries = await Trail(client, "Product", id); - // And with a caller, the trail answers. - Assert.Equal(HttpStatusCode.OK, (await app.Client.AsActor().GetAsync("/api/audit")).StatusCode); + Assert.Equal( + SampleActorAuthenticationHandler.DefaultActor, + Assert.Single(entries).Str("actor")); } /// From e954d0a47eeb933635d587f90882e8cab2086939 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Mon, 26 Oct 2026 17:32:02 +0300 Subject: [PATCH 08/44] Create the audit tables in a database that predates them The Audit page failed with a 500, `relation "AuditEntries" does not exist`. Aspire keeps Postgres on the `dotnetboost-sample-pgdata` volume, and a database created there before this branch added auditing never got the library's two tables: `EnsureCreated` does nothing to a database that already exists. The tests never saw it because each run starts from a fresh container. When the database already exists, the initialiser now creates the tables the model has and the database lacks, with their indexes, from EF's own create script. Existing tables and data are left alone. A column added to an existing table is still a migration this sample does not have. Verified against the old volume: both tables created, `/api/audit` answers 200, and all 139 tests pass. Co-Authored-By: Claude Opus 5.5 --- .../Persistence/DatabaseInitialiser.cs | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs b/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs index 66a813b..ec35cea 100644 --- a/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs +++ b/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs @@ -27,7 +27,7 @@ public static async Task InitialiseDatabaseAsync(this IServiceProvider services) var sp = scope.ServiceProvider; var db = sp.GetRequiredService(); - await db.Database.EnsureCreatedAsync(); + if (!await db.Database.EnsureCreatedAsync()) await db.CreateMissingTablesAsync(); var multiLingual = sp.GetRequiredService(); @@ -165,4 +165,50 @@ void Text(T entity, params (string Language, string Name, string Description) await db.SaveChangesAsync(); } + + /// + /// Adds the tables the model has and an existing database does not — and nothing else. + /// + /// EnsureCreated does nothing to a database that already exists, and Aspire keeps + /// this one on a data volume across runs. So a database created before a library joined the + /// model — DotNetBoost.Auditing's AuditEntries and AuditPropertyChanges, the + /// first time this branch runs against an older volume — never gets that library's tables, and + /// every read of them is a 500. This creates them from EF's own create script, keeping its + /// order, so the volume survives the upgrade with its data intact. + /// + /// Only missing tables and their indexes. A column added to an existing table is a + /// migration, which this sample does not have; for that, drop the volume. + /// + private static async Task CreateMissingTablesAsync(this AppDbContext db) + { + var existing = (await db.Database + .SqlQueryRaw( + "SELECT table_name AS \"Value\" FROM information_schema.tables WHERE table_schema = current_schema()") + .ToListAsync()) + .ToHashSet(StringComparer.Ordinal); + + var missing = db.Model.GetEntityTypes() + .Select(type => type.GetTableName()) + .OfType() + .Where(table => !existing.Contains(table)) + .ToHashSet(StringComparer.Ordinal); + + if (missing.Count == 0) return; + + var statements = db.Database.GenerateCreateScript() + .Split(";", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(statement => CreatedTable(statement) is { } table && missing.Contains(table)); + + foreach (var statement in statements) + await db.Database.ExecuteSqlRawAsync(statement); + } + + /// The table a CREATE TABLE or CREATE INDEX … ON statement is about. + private static string? CreatedTable(string statement) + { + var match = System.Text.RegularExpressions.Regex.Match( + statement, "^CREATE (?:UNIQUE )?(?:TABLE|INDEX \\S+ ON) (?:\"[^\"]+\"\\.)?\"([^\"]+)\""); + + return match.Success ? match.Groups[1].Value : null; + } } From 0f1827e55334a92fdf20cec909bc41e617a7a143 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Mon, 26 Oct 2026 17:54:08 +0300 Subject: [PATCH 09/44] Add a sort and a date range picker to the Audit page, and stop the table collapsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking an entity on the Audit page squeezed the table down to nothing. The panel body is a scrolling flex column and the table's root scrolls on its own, so when the rules card appeared the table gave up its height and became a 0px scroll box. `shrink-0` keeps it at full height and lets the page scroll. The From and To boxes are now one button that opens a two-month range calendar, with an ✕ to clear it. The range is applied once both ends are picked, and the query still holds the same `yyyy-mm-dd` pair, so what the API is asked has not changed. `@internationalized/date` is now a direct dependency; it was already installed through Nuxt UI. A "Sort by" select sits before "Per page": newest first, oldest first, entity, action, actor. It reorders the page on screen only. The API serves the trail newest first with no parameter for another order, and the cursor walk depends on that order. Also fixes the Action filter's options, which looked up `audit.actions.Created` and friends where the keys are lowercase. Co-Authored-By: Claude Opus 5.5 --- .../app/components/audit/Filters.vue | 124 ++++++++++++++---- clients/dashboard/app/pages/audit.vue | 35 ++++- clients/dashboard/app/types/index.d.ts | 6 + clients/dashboard/app/utils/format.ts | 12 ++ clients/dashboard/i18n/locales/ar.json | 13 +- clients/dashboard/i18n/locales/en.json | 13 +- clients/dashboard/i18n/locales/fr.json | 13 +- clients/dashboard/package-lock.json | 1 + clients/dashboard/package.json | 1 + 9 files changed, 184 insertions(+), 34 deletions(-) diff --git a/clients/dashboard/app/components/audit/Filters.vue b/clients/dashboard/app/components/audit/Filters.vue index b3808a7..9eda462 100644 --- a/clients/dashboard/app/components/audit/Filters.vue +++ b/clients/dashboard/app/components/audit/Filters.vue @@ -1,9 +1,12 @@ - -
- {{ $t('audit.pager.page', { n: page }) }} +
+ {{ range }} -
- - -
+
diff --git a/clients/dashboard/app/types/index.d.ts b/clients/dashboard/app/types/index.d.ts index df0ff7d..4845525 100644 --- a/clients/dashboard/app/types/index.d.ts +++ b/clients/dashboard/app/types/index.d.ts @@ -464,8 +464,8 @@ export interface AuditPage { /** Newest first. */ entries: AuditEntry[] /** - * How many matched, or `null` when the read did not ask — which this dashboard does not. - * `null` means "not counted" and never zero, so nothing may paginate against it. + * How many matched, or `null` when the read did not ask. The Audit page always asks, for its + * numbered pager. `null` means "not counted" and never zero, so nothing may paginate against it. */ total: number | null skip: number @@ -483,10 +483,8 @@ export interface AuditPage { * The token to send as `after` for the next page, or `null` when the walk is over. * * Not a promise that more entries exist: it is where this page ended, so the last full page - * carries one and the request after it comes back empty. Preferred to `skip` for walking a - * trail, which is why this dashboard never sends `skip` — an offset makes the database read and - * discard everything before the page, and an entry arriving between two requests shifts a row - * across the boundary. Sending both is a `400`. + * carries one and the request after it comes back empty. The Audit page does not use it: it + * pages by `skip` so it can number its pages, and sending both is a `400`. */ nextCursor: string | null } diff --git a/clients/dashboard/i18n/locales/ar.json b/clients/dashboard/i18n/locales/ar.json index 083e763..44dac77 100644 --- a/clients/dashboard/i18n/locales/ar.json +++ b/clients/dashboard/i18n/locales/ar.json @@ -250,7 +250,7 @@ "audit": { "title": "سجل التدقيق", "card": { - "description": "تقدّمها MapAuditEndpoints() من DotNetBoost.Auditing.API — نقاط نهاية المكتبة نفسها، لا نقاط كتبها هذا التطبيق. كل مرشّح أدناه معامل في الاستعلام المُرسَل إلى واجهة البرمجة، والصفحات تُستعرَض بالمؤشّر الذي تعيده لا بإزاحة." + "description": "تقدّمها MapAuditEndpoints() من DotNetBoost.Auditing.API — نقاط نهاية المكتبة نفسها، لا نقاط كتبها هذا التطبيق. كل مرشّح أدناه معامل في الاستعلام المُرسَل إلى واجهة البرمجة." }, "reload": "إعادة تحميل السجل", "loadError": "تعذّر تحميل السجل", @@ -317,9 +317,7 @@ "description": "كل المدخلات المكتوبة بالمعرّف الرابط نفسه. والتعديل ليس دائمًا مدخلًا واحدًا: إنشاء منتج بنص في عدة لغات يكتب مدخل المنتج ومدخل سجل الإنشاء، في نطاق خاص به." }, "pager": { - "page": "صفحة {n}", - "newer": "الأحدث", - "older": "الأقدم" + "range": "{from}–{to} من {total}" }, "rules": { "title": "ما يُسجَّل لـ {entity}", diff --git a/clients/dashboard/i18n/locales/en.json b/clients/dashboard/i18n/locales/en.json index b0dc13f..f56b485 100644 --- a/clients/dashboard/i18n/locales/en.json +++ b/clients/dashboard/i18n/locales/en.json @@ -250,7 +250,7 @@ "audit": { "title": "Audit trail", "card": { - "description": "Served by MapAuditEndpoints() from DotNetBoost.Auditing.API — library endpoints, not ones this application wrote. Every filter below is a parameter on the query the API is asked, and the pages are walked with the cursor it hands back rather than with an offset." + "description": "Served by MapAuditEndpoints() from DotNetBoost.Auditing.API — library endpoints, not ones this application wrote. Every filter below is a parameter on the query the API is asked." }, "reload": "Reload the trail", "loadError": "Could not load the trail", @@ -317,9 +317,7 @@ "description": "Every entry written under the same correlation id. An edit is not always one entry: creating a product with text in several languages writes the product's own entry and the creation trail's, in a scope of its own." }, "pager": { - "page": "Page {n}", - "newer": "Newer", - "older": "Older" + "range": "{from}–{to} of {total}" }, "rules": { "title": "What is recorded for {entity}", diff --git a/clients/dashboard/i18n/locales/fr.json b/clients/dashboard/i18n/locales/fr.json index 147be51..a502398 100644 --- a/clients/dashboard/i18n/locales/fr.json +++ b/clients/dashboard/i18n/locales/fr.json @@ -250,7 +250,7 @@ "audit": { "title": "Journal d'audit", "card": { - "description": "Servi par MapAuditEndpoints() de DotNetBoost.Auditing.API — les points de terminaison de la bibliothèque, pas ceux de cette application. Chaque filtre ci-dessous est un paramètre de la requête envoyée à l'API, et les pages se parcourent avec le curseur qu'elle renvoie plutôt qu'avec un décalage." + "description": "Servi par MapAuditEndpoints() de DotNetBoost.Auditing.API — les points de terminaison de la bibliothèque, pas ceux de cette application. Chaque filtre ci-dessous est un paramètre de la requête envoyée à l'API." }, "reload": "Recharger le journal", "loadError": "Impossible de charger le journal", @@ -317,9 +317,7 @@ "description": "Toutes les entrées écrites sous le même identifiant de corrélation. Une édition n'est pas toujours une seule entrée : créer un produit avec du texte en plusieurs langues écrit l'entrée du produit et celle du journal de création, dans une portée qui lui est propre." }, "pager": { - "page": "Page {n}", - "newer": "Plus récentes", - "older": "Plus anciennes" + "range": "{from}–{to} sur {total}" }, "rules": { "title": "Ce qui est enregistré pour {entity}", diff --git a/clients/dashboard/test/nuxt/useAuditTrail.spec.ts b/clients/dashboard/test/nuxt/useAuditTrail.spec.ts index e624035..366976c 100644 --- a/clients/dashboard/test/nuxt/useAuditTrail.spec.ts +++ b/clients/dashboard/test/nuxt/useAuditTrail.spec.ts @@ -8,14 +8,12 @@ import type { AuditEntry } from '~/types' /** * How the Audit page asks the trail its question, and what happens when the question changes. * - * Why this file exists. The three properties below were all wrong in a first cut of - * `useAuditTrail`, and none of them is visible from inside one request: + * Why this file exists. None of the three properties below is visible from inside one + * request: * *
    - *
  • the cursor outlived the filter. A cursor is a position in one ordered, filtered - * trail. Kept across a filter change it addresses a place in a trail that is no longer being - * read, so the API was handed `after` from the old walk together with the new filter — and - * answered with entries from the middle of a set nobody asked for, or with nothing at all, which + *
  • the page number must not outlive the filter. Page 2 of the whole trail can be past + * the end of a filter that matches one page, and the API answers that with nothing at all, which * reads as "no entries match" for a filter that matches plenty;
  • * *
  • the second question was answered with the first one's page. The entry is registered @@ -78,11 +76,9 @@ let entityReads: number let gate: Promise | null /** - * `GET api/audit`, close enough to the real one where it matters: it filters by actor, it pages by cursor, and it refuses `skip` and `after` together the way the - * API does — which is what makes "never send `skip`" an assertion rather than a convention. - * - * The cursor is the last entry's id. The real tokens are opaque, and nothing in the composable may - * read one, which is exactly why a stand-in can stand in. + * `GET api/audit`, close enough to the real one where it matters: it filters by actor, it pages by + * offset, it counts only when asked, and it refuses `skip` and `after` together the way the API + * does — which is what makes "never send `after`" an assertion rather than a convention. */ registerEndpoint('/api/audit', defineEventHandler(async (event) => { const query = getQuery(event) as Record @@ -98,22 +94,17 @@ registerEndpoint('/api/audit', defineEventHandler(async (event) => { const take = Number(query.take ?? 25) const matching = TRAIL.filter(entry => !query.actor || entry.actor === query.actor) - const start = query.after ? matching.findIndex(entry => entry.id === query.after) + 1 : 0 - const entries = matching.slice(start, start + take) + const skip = Number(query.skip ?? 0) + const entries = matching.slice(skip, skip + take) return { entries, - // Never counted: the dashboard does not ask for a total, and `null` is what the API answers - // when it has not been asked. A client that paginated against it would be paginating against - // "not counted". - total: null, - skip: 0, + // `null` unless asked for, which is what the API answers: "not counted", never zero. + total: query.includeTotal === 'true' ? matching.length : null, + skip, // How many came back, which is what the real endpoint reports — not the size that was asked - // for. Mimicked because a client that reads it as the page size reads a short page as a full - // one, and the pager's "is there more" hangs off exactly that. + // for. take: entries.length, - // Returned for every page that holds anything, the last one included. It says where this page - // ended and never that another exists, which is the other half of the same trap. nextCursor: entries.length ? entries[entries.length - 1]!.id : null } })) @@ -208,57 +199,39 @@ describe('useAuditTrail', () => { expect(trail.maxPageSize.value).toBe(50) }) - it('walks by cursor, and starts the walk again whenever the question changes', async () => { + it('pages by offset with a count, and goes back to page 1 whenever the question changes', async () => { const { trail } = await mountTrail() await vi.waitFor(() => expect(trail.entries.value).toHaveLength(3)) - // One entry a page, so the walk has somewhere to go. + // The count comes with every page: it is what the numbered pager is drawn from. + expect(reads.at(-1)).toMatchObject({ skip: '0', includeTotal: 'true' }) + expect(trail.total.value).toBe(3) + + // One entry a page, so there are pages to move between. await trail.setPageSize(1) expect(trail.entries.value.map(entry => entry.id)).toEqual(['e3']) expect(trail.page.value).toBe(1) - expect(trail.hasNewer.value).toBe(false) - expect(trail.hasOlder.value).toBe(true) - await trail.older() + // Straight to the last page, which only an offset can do. + await trail.goTo(3) - // The next page is asked for from where the last one ended, by the token the API handed back. - expect(reads.at(-1)).toMatchObject({ after: 'e3' }) - expect(trail.entries.value.map(entry => entry.id)).toEqual(['e2']) - expect(trail.page.value).toBe(2) + expect(reads.at(-1)).toMatchObject({ skip: '2', take: '1' }) + expect(trail.entries.value.map(entry => entry.id)).toEqual(['e1']) + expect(trail.page.value).toBe(3) - // Now the question changes. The cursor from the old walk must not go with it: `e3` is a - // position in the unfiltered trail, and bob has no entry there at all — sent together with - // his name the API answers empty, which reads as "bob changed nothing". + // Now the question changes. Bob has one entry, so page 3 of his trail is empty — kept across + // the change, it would read as "bob changed nothing". await trail.setFilters({ actor: 'bob@example.com' }) - expect(reads.at(-1)!.after).toBeUndefined() - expect(reads.at(-1)).toMatchObject({ actor: 'bob@example.com' }) + expect(reads.at(-1)).toMatchObject({ actor: 'bob@example.com', skip: '0' }) expect(trail.page.value).toBe(1) + expect(trail.total.value).toBe(1) expect(trail.entries.value.map(entry => entry.id)).toEqual(['e3']) - // Bob has exactly one entry and a page holds exactly one, so this is a full page that happens - // to be the last — and `nextCursor` says where the page ended, never that another exists. A - // cursor walk finds out by asking: the answer is an empty page with a step back from it, which - // is the honest end of a walk that deliberately never counted the trail. - expect(trail.hasOlder.value).toBe(true) - - await trail.older() - - expect(trail.entries.value).toEqual([]) - expect(trail.page.value).toBe(2) - expect(trail.hasNewer.value).toBe(true) - expect(trail.hasOlder.value).toBe(false) - - await trail.newer() - - expect(trail.entries.value.map(entry => entry.id)).toEqual(['e3']) - expect(reads.at(-1)!.after).toBeUndefined() - - // `skip` is never sent, by anything, ever: the API refuses it together with `after`, and an - // offset re-reads and discards the whole trail ahead of the page it serves. - expect(reads.every(read => read.skip === undefined)).toBe(true) + // `after` is never sent, by anything: the API refuses it together with `skip`. + expect(reads.every(read => read.after === undefined)).toBe(true) }) it('does not answer a new question with the answer already in flight', async () => { From 91cf3fc14c3231c6f3d8c1e00a91d2985a9f24d2 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Mon, 26 Oct 2026 19:32:56 +0300 Subject: [PATCH 11/44] Record the trail with Auditing's interceptor, on DotNetBoost.Auditing preview.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DotNetBoost.Auditing 1.0.0-preview.2 adds IgnoreEntity(), the class-level [AuditIgnore] for a type the application cannot annotate, and no longer throws on an excluded entity with a multi-column key. That was the whole reason this sample could not attach the library's SaveChangesInterceptor: MultiLingual's Translation is keyed on four columns and is saved beside every translated entity. - Bump the three Auditing packages to 1.0.0-preview.2. - Chain .UseMultiLingual(sp).UseAuditing(sp) on AppDbContext and add .IgnoreEntity() to AddAuditing(). - Give Category and Product HiLo keys (sequences CategoryIds and ProductIds), assigned at AddAsync, so a creation's audit entry goes in the caller's own save instead of a deferred second save nested inside MultiLingual's. DatabaseInitialiser creates the sequences on a database made before them, starting past the ids already there; the existing identity columns accept the explicit ids, so no migration is needed. - Remove the hand-written recording: CreationTrail.cs, the IAuditManager and AuditDiff calls in the Update/Delete handlers, and Application's reference to DotNetBoost.Auditing.Core. IAuditManager stays behind the audit endpoints and the dashboard's reads. - The trail now covers every entity the context saves except Translation — Setting, MediaFile, MediaAttachment and Language too — and the seed's first run, with no actor. - Tests: a category changed straight through AppDbContext is recorded with no handler involved; no Translation entry after a save in three languages; a creation's text survives in every language; a settings change is in the trail; and SchemaUpgradeTests boots on a database from before the HiLo keys. 148 tests, all passing. - README, ARCHITECTURE-SLICES.md, Program.cs and the dashboard comments describe the interceptor instead of the workaround. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE-SLICES.md | 8 +- Directory.Packages.props | 11 +- README.md | 259 ++++++++++-------- .../app/components/audit/EntryChanges.vue | 6 +- .../app/components/audit/Filters.vue | 13 +- .../app/composables/useAuditTrail.ts | 10 +- src/DotNetBoostSample.Api/Program.cs | 36 +-- .../Catalog/Categories/CreateCategory.cs | 22 +- .../Catalog/Categories/DeleteCategory.cs | 13 +- .../Catalog/Categories/UpdateCategory.cs | 34 +-- .../Catalog/Products/CreateProduct.cs | 17 +- .../Catalog/Products/DeleteProduct.cs | 16 +- .../Catalog/Products/UpdateProduct.cs | 24 +- .../Composition/CreationTrail.cs | 77 ------ .../DependencyInjection.cs | 5 - .../DotNetBoostSample.Application.csproj | 7 +- .../DependencyInjection.cs | 83 +++--- .../Persistence/AppDbContext.cs | 29 +- .../Configurations/CategoryConfiguration.cs | 13 + .../Configurations/ProductConfiguration.cs | 4 + .../Persistence/DatabaseInitialiser.cs | 77 +++++- .../Integration/AuditTrailTests.cs | 209 ++++++++++++-- .../Integration/SampleApp.cs | 30 +- .../Integration/SchemaUpgradeTests.cs | 92 +++++++ 24 files changed, 678 insertions(+), 417 deletions(-) delete mode 100644 src/DotNetBoostSample.Application/Composition/CreationTrail.cs create mode 100644 tests/DotNetBoostSample.Tests/Integration/SchemaUpgradeTests.cs diff --git a/ARCHITECTURE-SLICES.md b/ARCHITECTURE-SLICES.md index c09092a..e673ec5 100644 --- a/ARCHITECTURE-SLICES.md +++ b/ARCHITECTURE-SLICES.md @@ -5,7 +5,7 @@ the two disagreed the code won — §10 lists what changed on contact with the c written while the work sat on `feat/vertical-slices-cqrs` and still argues with itself about whether to merge it; §11.3 says how that ended. -The solution builds clean on `net8.0` and `net10.0` with `TreatWarningsAsErrors`, and all 139 tests +The solution builds clean on `net8.0` and `net10.0` with `TreatWarningsAsErrors`, and all 148 tests pass, including the integration suite that boots the real `Program.cs` against a throwaway PostgreSQL and drives every route over HTTP. No endpoint's URL, status code or JSON shape changed. @@ -57,8 +57,10 @@ compile against DotNetBoost's own `net8.0` TFM. This repo was **1,788 lines of C# in 13 files** when this was written, and the README's thesis is that the integration between three independent libraries is *five files, none of them large*. A fourth library has since been installed, and that count is now eight files — see the README; the -reasoning below is unaffected, and the one new file it would have changed its mind about -(`Composition/CreationTrail.cs`) is discussed there. +reasoning below is unaffected. The one new file it would have changed its mind about, +`Composition/CreationTrail.cs` — a creation's audit entry, written by hand in a scope of its own — +is gone again: DotNetBoost.Auditing 1.0.0-preview.2 added `IgnoreEntity()`, its capture is now +attached to the context, and its place on the list is one line of `Infrastructure/DependencyInjection.cs`. Clean architecture plus vertical slices turns one project into four and moves the composition behind a dispatcher. The behaviour does not change. For a monolith heading to a hundred endpoints that is the right trade; for a sample whose job is to make a composition claim visible in one diff --git a/Directory.Packages.props b/Directory.Packages.props index 63d7f00..51fc66f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,8 +11,9 @@ integration package pairing two of them. Each library is installed on its own and knows nothing about the others. tests/ asserts that stays true. - Auditing is on 1.0.0-preview.1 while the other three are on preview.2. Nothing - aligns those numbers — four independently versioned libraries are four release + All four happen to be on 1.0.0-preview.2 today, and nothing aligns those numbers: + Auditing reached preview.2 a release after the other three, for a fix of its own + (IgnoreEntity(), below). Four independently versioned libraries are four release cadences, which is the other half of what "no shared kernel" means. ────────────────────────────────────────────────────────────────────────── --> @@ -44,16 +45,16 @@ - + - + - + +
    {{ $t('audit.entry.correlationId') }}
    diff --git a/clients/dashboard/app/components/audit/Filters.vue b/clients/dashboard/app/components/audit/Filters.vue index 9eda462..021242e 100644 --- a/clients/dashboard/app/components/audit/Filters.vue +++ b/clients/dashboard/app/components/audit/Filters.vue @@ -19,7 +19,7 @@ import type { AuditAction, AuditEntityDescriptor, AuditQuery, AuditSort } from ' */ const props = defineProps<{ query: AuditQuery - /** From `GET api/audit/entities` — what this installation actually records. */ + /** From `GET api/audit/entities` — the entities this installation declares. */ entities: AuditEntityDescriptor[] /** The API's own bound on a page, from the same response. 0 until it has answered. */ maxPageSize: number @@ -49,12 +49,13 @@ const { t } = useI18n() const ANY = 'any' /** - * The entities the API says it records, and nothing else. + * The entities the API declares, and nothing else. * - * Not a list written down here: which entities are audited is the engine's own answer — it accounts - * for the attributes, for opt-in mode and for the kill switch — and this installation's two are - * `Category` and `Product` while `Setting`, `MediaFile` and `Translation` are rows in the same - * database that are deliberately not in the trail. + * Not a list written down here: which entities are declared, and whether each is audited, is the + * engine's own answer — it accounts for the attributes, for opt-in mode and for the kill switch. + * This installation declares `Category` and `Product`. The trail is wider than that — the API's + * capture also records `Setting`, `MediaFile` and the other libraries' rows, which "Any" shows — + * and `Translation` is the one entity it never records. */ const entityItems = computed(() => [ { label: t('audit.filters.anyEntity'), value: ANY, audited: true }, diff --git a/clients/dashboard/app/composables/useAuditTrail.ts b/clients/dashboard/app/composables/useAuditTrail.ts index 545aa9d..37df081 100644 --- a/clients/dashboard/app/composables/useAuditTrail.ts +++ b/clients/dashboard/app/composables/useAuditTrail.ts @@ -189,11 +189,11 @@ export function useAuditTrail() { /** * Narrows the trail to one edit — every entry written under the same correlation id. * - * Worth a control of its own because an edit in this application is not always one entry: a - * product created with text in three languages writes the product's entry and, in a scope of its - * own, the creation trail's — see `Composition/CreationTrail.cs`. The correlation id is what says - * those belong together, and reading them apart is how someone concludes the trail is recording - * twice. + * Worth a control of its own because an edit in this application is not always one entry: the + * API's capture records every entity a save touches, and one save can touch several — a settings + * group writes a row per value, and a product's images are rows of DotNetBoost.Media's own. The + * correlation id is one per save, which is what says those belong together, and reading them + * apart is how someone concludes the trail is recording twice. * * The other filters are cleared with it: the rest of an edit may well be another entity. */ diff --git a/src/DotNetBoostSample.Api/Program.cs b/src/DotNetBoostSample.Api/Program.cs index 62ea037..670b2bf 100644 --- a/src/DotNetBoostSample.Api/Program.cs +++ b/src/DotNetBoostSample.Api/Program.cs @@ -23,15 +23,16 @@ // // The fourth arrived without moving the other three. Auditing's whole presence in this file is // one delegate and one Map call; in the rest of the solution it is one Apply* line in -// AppDbContext, one block in AddInfrastructure, two attributes in Domain and one line in each of -// the six catalogue write slices. Nothing in the other three moved. That is the strongest +// AppDbContext, one block and one UseAuditing(sp) in AddInfrastructure, and two attributes in +// Domain — no handler mentions it. Nothing in the other three moved. That is the strongest // evidence here for what "no shared kernel" is worth, because it is the only one measured by // ADDING a library rather than by reading the ones already in — and the last two items on that // list are also where the cost showed up, which is the rest of this comment. // // What ties them together is eight files, all of them application and domain code, none of them // large, and each one a place where this application says two libraries are talking about the -// same thing — except the last two, which are where it says they cannot: +// same thing — except the last two, which are where it says one of them must leave the other's +// data alone: // // • Infrastructure/Persistence/AppDbContext.cs — all four. One database, four Apply* // calls, nothing else. @@ -52,14 +53,14 @@ // Auditing added two more, and they are the first on this list that exist because two libraries // DISAGREE rather than because they are talking about the same thing: // -// • Application/Composition/CreationTrail.cs — Auditing → MultiLingual. A creation's entry -// needs a second save, and a second save on this context -// would make MultiLingual rewrite the text the first one -// stored — so it is written in a scope of its own. It -// exists at all because the two libraries cannot both watch -// one DbContext: Infrastructure/DependencyInjection.cs has -// that finding, which is the most useful thing in this -// repository about what independence costs. +// • Infrastructure/DependencyInjection.cs — Auditing → MultiLingual, one line of it: +// .IgnoreEntity(). MultiLingual's row is keyed +// on four columns and an audit entry on one, and nobody +// here owns the type to put [AuditIgnore] on it — so the +// attribute is applied from the outside. Until +// DotNetBoost.Auditing 1.0.0-preview.2 there was no way to, +// the capture could not be attached to this context at all, +// and the catalogue's handlers recorded every change by hand. // • Domain/Catalog/{Category,Product}.cs — MultiLingual → Auditing. [AuditIgnore] // beside [MultiLingual] on the two translated properties: a // trail has no language, so a property whose value depends @@ -67,17 +68,18 @@ // which is why eight files are seven entries. // // Eight, counted the same way as five was. The README said then that a sixth would mean the claim -// had quietly stopped being true, and it has: this application now writes glue because two of its -// libraries cannot both watch one DbContext, which is a worse kind of glue than "these two tables -// are about one thing" and is worth saying out loud rather than absorbing into the count. +// had quietly stopped being true, and it has: two of these exist because the libraries disagree +// about what can be recorded, which is a worse kind of glue than "these two tables are about one +// thing" and is worth saying out loud rather than absorbing into the count. // -// Seven of the eight turn out to be abstraction-only — they name DotNetBoost.*.Core and nothing +// Six of the eight turn out to be abstraction-only — they name DotNetBoost.*.Core and nothing // else — which is why they sit in the application and domain layers beside the code that uses -// them. Only AppDbContext is infrastructure. Joining two libraries is not a persistence concern; +// them. The other two — AppDbContext and that one line of AddInfrastructure — are infrastructure, +// and both are about sharing one DbContext. Joining two libraries is not a persistence concern; // joining them in ONE DATABASE is. // // The count is eight rather than a rounder number because every file joining two libraries is on -// the list, including the two that are there because a library could not be used as documented. +// the list, including the two that are there because two libraries disagree. // // It was more. Writing alt text is not here any more — DotNetBoost.MultiLingual ships the // translation endpoints now, and the dashboard's editor calls them directly — and branding was a diff --git a/src/DotNetBoostSample.Application/Catalog/Categories/CreateCategory.cs b/src/DotNetBoostSample.Application/Catalog/Categories/CreateCategory.cs index 222cd2f..d841634 100644 --- a/src/DotNetBoostSample.Application/Catalog/Categories/CreateCategory.cs +++ b/src/DotNetBoostSample.Application/Catalog/Categories/CreateCategory.cs @@ -2,7 +2,6 @@ using DotNetBoostSample.Application.Abstractions.Cqrs; using DotNetBoostSample.Application.Abstractions.Persistence; using DotNetBoostSample.Application.Catalog.Shared; -using DotNetBoostSample.Application.Composition; using DotNetBoostSample.Application.Localization; using DotNetBoostSample.Domain.Catalog; using FluentValidation; @@ -43,28 +42,23 @@ public Validator() public sealed class Handler( IAppDbContext db, - TranslatedText translatedText, - CreationTrail trail) : IRequestHandler + TranslatedText translatedText) : IRequestHandler { /// public async Task HandleAsync(Command request, CancellationToken ct) { var category = new Category { Slug = request.Slug }; - db.Categories.Add(category); + // AddAsync, because adding is what assigns the HiLo id — see CategoryConfiguration. + await db.Categories.AddAsync(category, ct); - // One SaveChanges for the row and every language's text: MultiLingual's interceptor - // writes the queued translations in the same transaction as the insert. The save is - // TranslatedText's rather than this handler's because, with a base language - // configured, the language it runs in decides where each value lands — see that class. + // One SaveChanges for the row, every language's text and its audit entry: MultiLingual's + // interceptor writes the queued translations and Auditing's records the creation, in + // the same transaction as the insert. The save is TranslatedText's rather than this + // handler's because, with a base language configured, the language it runs in decides + // where each value lands — see that class. await translatedText.SaveAsync(db, category, request.Translations, ct); - // And the entry that says who created it. Every other write in the catalogue records - // itself INTO its own save; a creation is the one that cannot, because the id an entry - // is addressed by is generated by the INSERT above. CreationTrail is where that, and - // the second reason it needs a context of its own, are written down. - await trail.RecordAsync(category, ct); - return new CategoryCreated(category.Id, category.Slug); } } diff --git a/src/DotNetBoostSample.Application/Catalog/Categories/DeleteCategory.cs b/src/DotNetBoostSample.Application/Catalog/Categories/DeleteCategory.cs index bb54100..38c4eaf 100644 --- a/src/DotNetBoostSample.Application/Catalog/Categories/DeleteCategory.cs +++ b/src/DotNetBoostSample.Application/Catalog/Categories/DeleteCategory.cs @@ -1,5 +1,3 @@ -using DotNetBoost.Auditing.Core.Interfaces; -using DotNetBoost.Auditing.Core.Services; using DotNetBoost.Media.Core.Interfaces; using DotNetBoostSample.Application.Abstractions.Endpoints; using DotNetBoostSample.Application.Abstractions.Cqrs; @@ -22,8 +20,7 @@ public sealed record Command(int Id) : ICommand>; public sealed class Handler( IAppDbContext db, - IMediaManager media, - IAuditManager audit) : IRequestHandler> + IMediaManager media) : IRequestHandler> { /// public async Task> HandleAsync(Command request, CancellationToken ct) @@ -47,11 +44,9 @@ public async Task> HandleAsync(Command request, CancellationToken // and `category.Slug` is only reliable while the entity is still there to read it from. var slug = category.Slug; - // The entry carries the row's final values, and it outlives the row: nothing cascades - // to the trail, so after the save below this is the only record of what this category - // said. Recorded before the Remove so that both are in the same SaveChanges. - await audit.For(category).DeletedAsync(AuditDiff.FinalValues(category), ct: ct); - + // DotNetBoost.Auditing's interceptor records the delete in the save below, with the + // row's final values — and the entry outlives the row: nothing cascades to the trail, + // so afterwards it is the only record of what this category said. db.Categories.Remove(category); await db.SaveChangesAsync(ct); diff --git a/src/DotNetBoostSample.Application/Catalog/Categories/UpdateCategory.cs b/src/DotNetBoostSample.Application/Catalog/Categories/UpdateCategory.cs index 6133d89..09fb2cd 100644 --- a/src/DotNetBoostSample.Application/Catalog/Categories/UpdateCategory.cs +++ b/src/DotNetBoostSample.Application/Catalog/Categories/UpdateCategory.cs @@ -1,5 +1,3 @@ -using DotNetBoost.Auditing.Core.Interfaces; -using DotNetBoost.Auditing.Core.Services; using DotNetBoostSample.Application.Abstractions.Endpoints; using DotNetBoostSample.Application.Abstractions.Cqrs; using DotNetBoostSample.Application.Abstractions.Persistence; @@ -34,8 +32,7 @@ public Validator() public sealed class Handler( IAppDbContext db, - TranslatedText translatedText, - IAuditManager audit) : IRequestHandler> + TranslatedText translatedText) : IRequestHandler> { /// Answers with the slug, which is what the message is named after. public async Task> HandleAsync(Command request, CancellationToken ct) @@ -44,30 +41,17 @@ public async Task> HandleAsync(Command request, CancellationToken if (category is null) return Error.NotFound("Catalog.CategoryNotFound", request.Id); - // The before-image, which the explicit path has instead of a change tracker's original - // values. A second read of a row already in memory, and the library's documentation - // says plainly that this is what it costs: AuditDiff compares two instances, so there - // has to be a second instance. - // - // AsNoTracking, or EF would hand back the tracked entity — the same object that is - // about to be mutated, which would make every diff empty. - var before = await db.Categories.AsNoTracking().FirstAsync(c => c.Id == request.Id, ct); - category.Slug = request.Slug; - // Recorded BEFORE the save, which is what keeps the change and its trail in one - // transaction: the EF Core store tracks the entry rather than writing it, so - // SaveAsync below commits the slug and the entry together or neither. + // The audit entry is recorded by DotNetBoost.Auditing's interceptor, inside this save, + // from the change tracker's original and current values — so the slug and its entry + // commit together or neither does. // - // Guarded on Count, because the engine refuses to write an update of nothing and a PUT - // that changed nothing is not an error. It happens: the editor posts the slug back - // unchanged whenever only the translations were edited. Name and Description do move - // in that case and are in this list — they are dropped by the engine instead, for the - // reason written on Product.Name, so no entry is written either way. - var changes = AuditDiff.Between(before, category); - - if (changes.Count > 0) await audit.For(category).UpdatedAsync(changes, ct: ct); - + // A PUT that changed nothing records nothing, and it happens: the editor posts the slug + // back unchanged whenever only the translations were edited. With a base language + // configured Name and Description do move in that case — they are columns then — and + // the engine drops them for the reason written on Product.Name, so no entry is written + // either way. await translatedText.SaveAsync(db, category, request.Translations, ct); return category.Slug; diff --git a/src/DotNetBoostSample.Application/Catalog/Products/CreateProduct.cs b/src/DotNetBoostSample.Application/Catalog/Products/CreateProduct.cs index 21d6506..6c10947 100644 --- a/src/DotNetBoostSample.Application/Catalog/Products/CreateProduct.cs +++ b/src/DotNetBoostSample.Application/Catalog/Products/CreateProduct.cs @@ -3,7 +3,6 @@ using DotNetBoostSample.Application.Abstractions.Persistence; using DotNetBoostSample.Application.Abstractions.Results; using DotNetBoostSample.Application.Catalog.Shared; -using DotNetBoostSample.Application.Composition; using DotNetBoostSample.Application.Localization; using DotNetBoostSample.Domain.Catalog; using FluentValidation; @@ -49,8 +48,7 @@ public Validator() public sealed class Handler( IAppDbContext db, - TranslatedText translatedText, - CreationTrail trail) : IRequestHandler> + TranslatedText translatedText) : IRequestHandler> { /// public async Task> HandleAsync(Command request, CancellationToken ct) @@ -81,16 +79,15 @@ public async Task> HandleAsync(Command request, Cancellat CategoryId = request.CategoryId }; - db.Products.Add(product); + // AddAsync, because adding is what gives the product its id: a HiLo key, assigned + // before the save rather than by the INSERT. That is what lets DotNetBoost.Auditing's + // interceptor write the Created entry in the same SaveChanges as the row and its + // translations — see ProductConfiguration. + await db.Products.AddAsync(product, ct); + // One save for the row, every language's text, and the entry that says who created it. await translatedText.SaveAsync(db, product, request.Translations, ct); - // And who created it — after the save, because the id the entry is addressed by is the - // database's to assign, and in a unit of work of its own, because this one cannot be - // saved twice without MultiLingual rewriting the text just stored. CreationTrail has - // both reasons and what they cost. - await trail.RecordAsync(product, ct); - return new ProductCreated(product.Id, product.Sku); } } diff --git a/src/DotNetBoostSample.Application/Catalog/Products/DeleteProduct.cs b/src/DotNetBoostSample.Application/Catalog/Products/DeleteProduct.cs index 6c2b5ef..2ff90c4 100644 --- a/src/DotNetBoostSample.Application/Catalog/Products/DeleteProduct.cs +++ b/src/DotNetBoostSample.Application/Catalog/Products/DeleteProduct.cs @@ -1,5 +1,3 @@ -using DotNetBoost.Auditing.Core.Interfaces; -using DotNetBoost.Auditing.Core.Services; using DotNetBoost.Media.Core.Interfaces; using DotNetBoostSample.Application.Abstractions.Endpoints; using DotNetBoostSample.Application.Abstractions.Cqrs; @@ -22,8 +20,7 @@ public sealed record Command(int Id) : ICommand>; public sealed class Handler( IAppDbContext db, - IMediaManager media, - IAuditManager audit) : IRequestHandler> + IMediaManager media) : IRequestHandler> { /// public async Task> HandleAsync(Command request, CancellationToken ct) @@ -45,14 +42,11 @@ public async Task> HandleAsync(Command request, CancellationToken // Read before the row goes, for the same reason as the category's slug. var sku = product.Sku; - // The entry survives the row, with its final values — see DeleteCategory's handler. - // It is also the one place the trail and the images disagree about what "gone" means: - // the files this product held are deleted above, and the entry says only that the - // product was deleted. A trail of attachments would be DotNetBoost.Media's to keep. - await audit.For(product).DeletedAsync(AuditDiff.FinalValues(product), ct: ct); - // The product's own translations ARE removed automatically: MultiLingual's SaveChanges - // interceptor sees the entity being deleted. + // interceptor sees the entity being deleted. And the Deleted entry, with the row's + // final values, is Auditing's interceptor's — see DeleteCategory's handler. The files + // this product held get entries of their own, as MediaFile and MediaAttachment rows + // deleted or detached by the call above. db.Products.Remove(product); await db.SaveChangesAsync(ct); diff --git a/src/DotNetBoostSample.Application/Catalog/Products/UpdateProduct.cs b/src/DotNetBoostSample.Application/Catalog/Products/UpdateProduct.cs index 85827fa..ff093a4 100644 --- a/src/DotNetBoostSample.Application/Catalog/Products/UpdateProduct.cs +++ b/src/DotNetBoostSample.Application/Catalog/Products/UpdateProduct.cs @@ -1,5 +1,3 @@ -using DotNetBoost.Auditing.Core.Interfaces; -using DotNetBoost.Auditing.Core.Services; using DotNetBoostSample.Application.Abstractions.Endpoints; using DotNetBoostSample.Application.Abstractions.Cqrs; using DotNetBoostSample.Application.Abstractions.Persistence; @@ -37,8 +35,7 @@ public Validator() public sealed class Handler( IAppDbContext db, - TranslatedText translatedText, - IAuditManager audit) : IRequestHandler> + TranslatedText translatedText) : IRequestHandler> { /// Answers with the SKU, which is what the message is named after. public async Task> HandleAsync(Command request, CancellationToken ct) @@ -53,25 +50,14 @@ public async Task> HandleAsync(Command request, CancellationToken if (!await db.Categories.AnyAsync(c => c.Id == request.CategoryId, ct)) return Error.NotFound("Catalog.CategoryNotFound", request.CategoryId); - // The before-image. UpdateCategory's handler explains the read and the AsNoTracking. - var before = await db.Products.AsNoTracking().FirstAsync(p => p.Id == request.Id, ct); - product.Sku = request.Sku; product.Price = request.Price; product.CategoryId = request.CategoryId; - // What moved, read off the type rather than written out here — so a column added to - // Product tomorrow is in the trail without this line changing, which is the one thing - // an if-chain per property reliably fails to do. Name and Description are compared and - // then dropped by the engine, because they carry [AuditIgnore]: see Product.Name. - // - // Recorded BEFORE the save, which is what keeps the change and its trail in one - // transaction — the EF Core store tracks the entry rather than writing it, so SaveAsync - // below commits the row and the entry together or neither. - var changes = AuditDiff.Between(before, product); - - if (changes.Count > 0) await audit.For(product).UpdatedAsync(changes, ct: ct); - + // The audit entry is the save's, not this handler's: DotNetBoost.Auditing's interceptor + // reads what moved off the change tracker, old value → new, and records it in the same + // SaveChanges. Name and Description are dropped because they carry [AuditIgnore] — see + // Product.Name — and a save that moved nothing else records nothing. await translatedText.SaveAsync(db, product, request.Translations, ct); return product.Sku; diff --git a/src/DotNetBoostSample.Application/Composition/CreationTrail.cs b/src/DotNetBoostSample.Application/Composition/CreationTrail.cs deleted file mode 100644 index 685dc9f..0000000 --- a/src/DotNetBoostSample.Application/Composition/CreationTrail.cs +++ /dev/null @@ -1,77 +0,0 @@ -using DotNetBoost.Auditing.Core.Interfaces; -using DotNetBoost.Auditing.Core.Services; -using DotNetBoostSample.Application.Abstractions.Persistence; -using Microsoft.Extensions.DependencyInjection; - -namespace DotNetBoostSample.Application.Composition; - -/// -/// Records that an entity was created, in a unit of work of its own — the one entry this -/// application writes that is not committed with the change it describes, and the sixth -/// place where two of these libraries have to be told about each other. -/// -/// Why the catalogue records its own changes at all. DotNetBoost.Auditing normally -/// needs no help: UseAuditing(sp) attaches a SaveChangesInterceptor, it reads the -/// change tracker, and no handler mentions auditing ever again. That interceptor cannot be attached -/// to this application's context — Translation has a composite key an audit entry cannot -/// address — so the six write slices record explicitly instead. -/// Infrastructure/DependencyInjection.cs has that finding in full. Five of the six are one -/// line and cost nothing: the EF Core store tracks an entry rather than writing it, so an -/// update or a delete recorded before the save rides along in it. -/// -/// Why a creation is the one that cannot. An entry is addressed by the entity's key, -/// and a store-generated key does not exist until the INSERT has run. So a creation can only -/// be recorded afterwards — which is exactly the case the library's own interceptor defers to a -/// second pass. -/// -/// And why that second save cannot be this request's. The obvious shape — -/// await audit.For(product).CreatedAsync(…); await db.SaveChangesAsync(ct); — is wrong here, -/// and wrong in a way that costs data rather than a test: a second SaveChanges on a context -/// still tracking a [MultiLingual] entity writes that entity's in-memory text over -/// the translations the first save stored. For a product created through TranslatedText -/// those properties were never assigned — the text went to translation rows, not to the instance — -/// so the second save stores empty values for the request's language and the name reads back as -/// "". Two integration tests caught it, which is how it was found. -/// -/// So the entry goes in a scope of its own: a second , tracking -/// nothing but the entry and saving only it. The [MultiLingual] entity is not in that -/// context, so there is nothing for MultiLingual's interceptor to rewrite. -/// -/// What that costs, plainly. A creation and its trail are in two transactions. If the -/// first commits and this does not, the row exists and its creation is not recorded — the window -/// the library closes for its own interceptor by owning a transaction around both passes, which a -/// handler holding has no way to open. The honest fix is not more code -/// here: it is keys assigned in the application rather than by the database, which would put a -/// creation back on the single-save path with everything else. -/// -/// Scoped, like the other composition services, and injected into the two create slices so -/// that neither of them has to know any of the above — only that recording a creation is a call -/// rather than a save. -/// -public sealed class CreationTrail(IServiceScopeFactory scopes) -{ - /// - /// Records 's creation, with its initial values. - /// - /// - /// The entity as created, after the save that assigned its key. It is read, never - /// tracked: the audit engine takes the entity name, the key and the type off it, and - /// reads its properties — minus the ones carrying - /// [AuditIgnore], which the engine drops. - /// - /// The request's token. - public async Task RecordAsync(object entity, CancellationToken ct) - { - using var scope = scopes.CreateScope(); - - var audit = scope.ServiceProvider.GetRequiredService(); - var db = scope.ServiceProvider.GetRequiredService(); - - // Tracked by the new scope's context rather than written, like every other entry this - // application records — so this save is what makes it durable, and it is the only thing in - // that context to save. - await audit.For(entity).CreatedAsync(AuditDiff.InitialValues(entity), ct: ct); - - await db.SaveChangesAsync(ct); - } -} diff --git a/src/DotNetBoostSample.Application/DependencyInjection.cs b/src/DotNetBoostSample.Application/DependencyInjection.cs index cb8f60c..9893fb1 100644 --- a/src/DotNetBoostSample.Application/DependencyInjection.cs +++ b/src/DotNetBoostSample.Application/DependencyInjection.cs @@ -70,11 +70,6 @@ public static IServiceCollection AddApplication(this IServiceCollection services // each take an IMultiLingualManager to hand straight on. services.AddScoped(); - // And the creation half of the audit trail — Auditing × MultiLingual, in the awkward - // direction. Scoped like the other two, and the reason it is a service at all rather than - // three lines in the two create slices is that it needs a SECOND scope: see CreationTrail. - services.AddScoped(); - return services; } diff --git a/src/DotNetBoostSample.Application/DotNetBoostSample.Application.csproj b/src/DotNetBoostSample.Application/DotNetBoostSample.Application.csproj index f0776b6..acb5574 100644 --- a/src/DotNetBoostSample.Application/DotNetBoostSample.Application.csproj +++ b/src/DotNetBoostSample.Application/DotNetBoostSample.Application.csproj @@ -18,10 +18,9 @@ - - + diff --git a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs index 3a3765f..babd8f8 100644 --- a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs +++ b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs @@ -1,6 +1,7 @@ using DotNetBoost.Auditing.Core; using DotNetBoost.Media.Core.Interfaces; using DotNetBoost.MultiLingual.Core; +using DotNetBoost.MultiLingual.Core.Models; using DotNetBoost.Settings.Core.Interfaces; using DotNetBoostSample.Application.Abstractions.Persistence; using DotNetBoostSample.Application.Composition; @@ -102,9 +103,10 @@ public static IServiceCollection AddInfrastructure( // connection string is read from configuration by the host rather than through // AddNpgsqlDbContext. // - // There is NO `.UseAuditing(sp)` here, and that is the one thing on this branch that did - // not go the way the libraries' READMEs describe. See the Auditing block below: it is not - // a preference, and it is not an omission. + // `UseAuditing(sp)` adds DotNetBoost.Auditing's SaveChangesInterceptor, which reads the + // change tracker on every save and records what moved — so no handler mentions auditing. + // Two independent libraries' interceptors on one context, chained in a line and knowing + // nothing of each other. What it took to make that true is in the Auditing block below. // // `ReplaceService` is the one addition the base language asked for here: the model this // context builds depends on that option, and EF's own cache key does not. See @@ -113,6 +115,7 @@ public static IServiceCollection AddInfrastructure( services.AddDbContext((sp, options) => options.UseNpgsql(connectionString) .UseMultiLingual(sp) + .UseAuditing(sp) .ReplaceService()); // The application layer's view of the same context — the two DbSets and SaveChangesAsync. @@ -248,38 +251,42 @@ public static IServiceCollection AddInfrastructure( // in two tables of its own, so no column, no collection and no migration appears on an // entity because it is audited. // - // ── And one thing is missing that the library's README would have here ── + // ── What it took to attach the capture to THIS context ─────────────── // - // `o.UseAuditing(sp)` on the context above — the SaveChangesInterceptor that reads the - // change tracker, which is how an application normally gets a trail without a line in a - // handler. It cannot be attached to THIS context, and the reason is the most useful thing - // this sample has learned from installing a fourth library: + // `o.UseAuditing(sp)` above records every change this context saves, and two things + // stood between this application and that line. Both are written down here because they + // are what happens when two libraries that share nothing meet in one DbContext. // - // Translation has a composite primary key (EntityName, EntityId, PropertyName, - // LanguageCode). An audit entry records a single key. Give the entity a surrogate key, - // exclude it with [AuditIgnore], or record against it by hand with audit.For(name, id). + // 1. Translation. DotNetBoost.MultiLingual saves one of these beside every translated + // entity, keyed on four columns (EntityName, EntityId, PropertyName, LanguageCode). + // An audit entry records a single key, so the capture cannot address it — and nobody + // here owns the type to put [AuditIgnore] on it. `IgnoreEntity()` below + // is that attribute, applied from the outside: the class-level [AuditIgnore] for a + // type the application cannot annotate. Neither library learned the other exists. + // Translated text is out of the trail anyway — see Product.Name — so ignoring its + // rows loses nothing the trail was meant to carry. // - // That is the library's own exception, thrown on the first save that tracks a translation - // row — which is every catalogue write. All three of its suggestions are addressed to - // whoever owns the entity, and nobody here owns it: Translation is DotNetBoost. - // MultiLingual's, its lack of a surrogate key is documented as deliberate ("the natural - // key is unique by definition"), and an attribute cannot be applied to a type from the - // outside. RequireOptIn does not help either — the key is read before the engine asks - // whether the entity is audited at all. + // 2. Keys. A creation's entry is addressed by the new row's id, and a key the database + // generates does not exist until the INSERT has run — so for such an entity the + // interceptor writes the Created entry in a SECOND SaveChanges, nested inside the + // first one's SavedChanges, under a transaction it opens itself. On this context that + // nested save runs through MultiLingual's interceptor as well, while MultiLingual is + // somewhere between capturing a [MultiLingual] entity's text and writing it — and a + // second save of a just-created entity is exactly how this application once lost a + // product's name, an empty one written over the stored one (git history has + // CreationTrail.cs, the workaround it needed). Whether the nested save is safe depends + // on the order the two interceptors run in and on internals neither library promises. + // So Category and Product take their keys from HiLo sequences instead (see their + // configurations): the id is assigned when the entity is ADDED, every creation's entry + // goes into the caller's own save, and there is no second save to reason about. The + // libraries' own entities already assign their keys in code. // - // So this application records explicitly instead, which is the third suggestion and a - // first-class path in the library rather than a workaround: the six catalogue write slices - // each report their own change through IAuditManager and AuditDiff. What that costs is in - // the README under "What this costs"; what it buys back is that an entry is appended to - // this DbContext and committed by the SAME SaveChanges as the change it describes, so an - // update and its trail still land together or neither does. A creation is the exception, - // and Application/Composition/CreationTrail.cs is the exception written down. - // - // The honest summary: two independent libraries, in one DbContext, incompatible at the - // capture level — and no shared kernel to arbitrate, which is exactly the trade this - // repository is about. An ignore-list on the builder (`o.Ignore()`) would - // close it without either library knowing about the other, and that is what this sample - // would ask the library for next. + // What that buys is the guarantee the library is built around, now without exceptions: + // a change and its entry are in one SaveChanges, so they commit together or neither does. + // And every mapped entity is captured unless it is ignored — Setting, MediaFile, + // MediaAttachment and Language as well as the catalogue — because that is the library's + // default, and an edit to branding is as much a change somebody made as an edit to a + // product. var auditing = services .AddAuditing() // The trail goes in AppDbContext, not in a store of its own, and that is what the @@ -288,15 +295,19 @@ public static IServiceCollection AddInfrastructure( // entry in one transaction. The library's Dapper and MongoDb stores write when they // are called instead, and atomicity is then the application's to arrange. .UseEntityFrameworkCore() - // The application's own two entities, by type — and on the explicit path this is - // load-bearing rather than decorative. It is what lets the engine apply a type's - // [AuditIgnore] and [AuditRedact] rules to a write addressed by name, what a prune - // would read a per-entity retention from, and what GET /api/audit/entities lists. + // The application's own two entities, by type. The interceptor reads a type's + // [AuditIgnore] and [AuditRedact] rules off the tracked entity itself, so this is not + // what applies them; it is what GET /api/audit/entities lists and describes, and what + // a prune would read a per-entity retention from. // // DeclareEntities rather than DeclareEntitiesFrom(assembly), because the scan declares // the types that CARRY one of the library's attributes and these two carry none — // which is the point above restated. - .DeclareEntities(typeof(Category), typeof(Product)); + .DeclareEntities(typeof(Category), typeof(Product)) + // DotNetBoost.MultiLingual's row, kept out of the trail — see above. Build() refuses a + // type that is both declared and ignored, and the declaration above names only this + // application's own two, so the two lines cannot disagree. + .IgnoreEntity(); // No retention is configured, deliberately. Retention does nothing until a prune runs, // this sample schedules none, and POST /api/audit/prune is not mapped — so a number here diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs b/src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs index 86debde..085579a 100644 --- a/src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs +++ b/src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs @@ -23,20 +23,20 @@ namespace DotNetBoostSample.Infrastructure.Persistence; /// /// /// Auditing is the newest of the four and the one that says most about the arrangement. Its two -/// tables are mapped here like any other library's — but its capture is not attached to -/// this context, because Translation has a composite key that an audit entry cannot -/// address, and neither the attribute that would exclude it nor the key that would fix it belongs -/// to this application. So the catalogue's writes record themselves, one line per write slice. -/// DependencyInjection.cs has the whole finding; it is the one place where two of these -/// libraries, in one database, do not simply coexist. +/// tables are mapped here like any other library's, and its capture is attached to this context in +/// DependencyInjection.cs — beside MultiLingual's, on the same save. Two things had to be +/// true for that: MultiLingual's Translation, keyed on four columns, is kept out of the +/// trail with IgnoreEntity<Translation>(), and the two entities here take HiLo keys +/// so that a creation's entry is written in the save that creates the row. That file has both. /// /// -/// It is also the one of the eight composition files that is infrastructure. The other -/// seven — MediaText, MediaTextCleanup, MultiLingualSchemaContributor, -/// BrandingProjector, CreationTrail and the two entities' attributes — depend on -/// DotNetBoost.*.Core and nothing else, so they live in the application and domain layers -/// beside the code that uses them. Joining two libraries turns out not to be a persistence -/// concern; joining them in one database is, and that is this file. +/// It is also one of only two of the eight composition files that are infrastructure, and +/// the other is that one line in DependencyInjection.cs. The remaining six — +/// MediaText, MediaTextCleanup, MultiLingualSchemaContributor, +/// BrandingProjector and the two entities' attributes — depend on DotNetBoost.*.Core +/// and nothing else, so they live in the application and domain layers beside the code that uses +/// them. Joining two libraries turns out not to be a persistence concern; joining them in one +/// database is, and both infrastructure files are about exactly that. /// /// /// is the application layer's view of it: the two DbSets and @@ -95,9 +95,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // side effect: the entry that records the deletion is the only copy of what the row said. // // Nothing is added to Category or Product by this call. That is the whole of the schema - // side, and it is deliberately separate from what makes a change get recorded — which - // here is the write slices calling IAuditManager, because the interceptor that would do - // it for them cannot be attached to this context. See DependencyInjection.cs. + // side, and it is deliberately separate from what makes a change get recorded — which is + // `UseAuditing(sp)` on the options, in DependencyInjection.cs. modelBuilder.ApplyAuditing(); } } diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs b/src/DotNetBoostSample.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs index 6b844c3..280d26c 100644 --- a/src/DotNetBoostSample.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs +++ b/src/DotNetBoostSample.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs @@ -31,6 +31,19 @@ public void Configure(EntityTypeBuilder category) { category.ToTable("Categories"); category.HasKey(c => c.Id); + + // The id comes from a HiLo sequence rather than an identity column: EF reserves a block of + // ids from "CategoryIds" and hands one out when the entity is ADDED, before anything is saved. + // That is what keeps a creation's audit entry in the same SaveChanges as the row: + // DotNetBoost.Auditing defers the entry for a key the INSERT has yet to generate, to a + // second save nested inside the first — on a context MultiLingual is writing translations + // through at the same moment. Infrastructure/DependencyInjection.cs has why that is worth + // avoiding, and DatabaseInitialiser creates the sequence on a database made before it + // existed. + // + // Add with AddAsync for the same reason: a block that has run out is refilled from the + // database, and AddAsync is the overload that does that without blocking. + category.Property(c => c.Id).UseHiLo("CategoryIds"); category.Property(c => c.Slug).HasMaxLength(128).IsRequired(); category.HasIndex(c => c.Slug).IsUnique().HasDatabaseName(SlugIndex); } diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/src/DotNetBoostSample.Infrastructure/Persistence/Configurations/ProductConfiguration.cs index 3d8e1f0..85c641a 100644 --- a/src/DotNetBoostSample.Infrastructure/Persistence/Configurations/ProductConfiguration.cs +++ b/src/DotNetBoostSample.Infrastructure/Persistence/Configurations/ProductConfiguration.cs @@ -26,6 +26,10 @@ public void Configure(EntityTypeBuilder product) { product.ToTable("Products"); product.HasKey(p => p.Id); + + // HiLo, from a sequence of its own — see CategoryConfiguration for why a key assigned + // before the save is what keeps a creation's audit entry in that save. + product.Property(p => p.Id).UseHiLo("ProductIds"); product.Property(p => p.Sku).HasMaxLength(64).IsRequired(); product.Property(p => p.Price).HasPrecision(18, 2); product.HasIndex(p => p.Sku).IsUnique().HasDatabaseName(SkuIndex); diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs b/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs index ec35cea..aee5229 100644 --- a/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs +++ b/src/DotNetBoostSample.Infrastructure/Persistence/DatabaseInitialiser.cs @@ -4,6 +4,7 @@ using DotNetBoostSample.Domain.Catalog; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace DotNetBoostSample.Infrastructure.Persistence; @@ -12,12 +13,12 @@ namespace DotNetBoostSample.Infrastructure.Persistence; /// Settings, Media and MultiLingual working on first run. A real application would use /// dotnet ef migrations; a sample you start with one command should not make you run two. /// -/// The audit trail starts empty, on purpose. This seeder writes through the libraries -/// and the DbContext directly rather than through the catalogue's write slices, and the -/// slices are what record entries — there is no capture interceptor on this context to pick the -/// seed up (see DependencyInjection.cs). So the trail begins with the first change somebody -/// makes, which is also the more useful first thing to read: nobody is served by forty entries -/// that say "the seed did it". +/// The audit trail starts with the seed. Everything below saves through the same +/// DbContext the requests do, and DotNetBoost.Auditing's interceptor is attached to it — so +/// the three languages, two categories and three products are each recorded as created, by nobody: +/// there is no request here and so no actor, and an unknown actor is the honest answer for a +/// startup job. Their translation rows are not, because Translation is ignored (see +/// DependencyInjection.cs). It happens once, on the run that creates the catalogue. ///
public static class DatabaseInitialiser { @@ -27,7 +28,11 @@ public static async Task InitialiseDatabaseAsync(this IServiceProvider services) var sp = scope.ServiceProvider; var db = sp.GetRequiredService(); - if (!await db.Database.EnsureCreatedAsync()) await db.CreateMissingTablesAsync(); + if (!await db.Database.EnsureCreatedAsync()) + { + await db.CreateMissingTablesAsync(); + await db.CreateMissingSequencesAsync(); + } var multiLingual = sp.GetRequiredService(); @@ -127,7 +132,9 @@ void Text(T entity, params (string Language, string Name, string Description) var furniture = new Category { Slug = "furniture" }; var lighting = new Category { Slug = "lighting" }; - db.Categories.AddRange(furniture, lighting); + // AddRangeAsync, because adding is what assigns a HiLo id — and the first add of a run + // reads a block of them from the database. See CategoryConfiguration. + await db.Categories.AddRangeAsync(furniture, lighting); Text(furniture, ("en", "Furniture", "Tables, chairs and desks."), @@ -146,7 +153,7 @@ void Text(T entity, params (string Language, string Name, string Description) var chair = new Product { Sku = "CHAIR-001", Price = 189.00m, CategoryId = furniture.Id }; var lamp = new Product { Sku = "LAMP-001", Price = 79.00m, CategoryId = lighting.Id }; - db.Products.AddRange(desk, chair, lamp); + await db.Products.AddRangeAsync(desk, chair, lamp); Text(desk, ("en", "Oak writing desk", "Solid oak, two drawers."), @@ -203,6 +210,58 @@ private static async Task CreateMissingTablesAsync(this AppDbContext db) await db.Database.ExecuteSqlRawAsync(statement); } + /// + /// Adds the HiLo sequences the model has and an existing database does not, each starting past + /// the ids its table already holds. + /// + /// The same upgrade as , for the keys. Category and + /// Product used to be identity columns and now draw their ids from sequences of their own, so + /// a volume created before that has the tables and their rows but not the sequences. Created + /// as EF's own script would create them — same name, start and increment — and then moved to + /// one past the highest id in the table, or the first block handed out would collide with the + /// rows already there. + /// + /// Only a sequence that was missing is moved. One that exists is EF's to advance, and + /// setting it back to the table's highest id would hand out again whatever blocks a running + /// process still holds. + /// + /// The columns themselves keep their identity: GENERATED BY DEFAULT accepts the + /// id EF now sends, so the old default simply goes unused rather than needing a migration to + /// drop. + /// + private static async Task CreateMissingSequencesAsync(this AppDbContext db) + { + var existing = (await db.Database + .SqlQueryRaw( + "SELECT sequence_name AS \"Value\" FROM information_schema.sequences WHERE sequence_schema = current_schema()") + .ToListAsync()) + .ToHashSet(StringComparer.Ordinal); + + var keys = db.Model.GetEntityTypes() + .SelectMany(type => type.GetProperties(), (type, property) => (type, property)) + .Where(p => p.property.GetValueGenerationStrategy() == NpgsqlValueGenerationStrategy.SequenceHiLo) + .Select(p => ( + Table: p.type.GetTableName()!, + Column: p.property.GetColumnName(), + Sequence: db.Model.FindSequence(p.property.GetHiLoSequenceName()!)!)) + .Where(key => !existing.Contains(key.Sequence.Name)); + + foreach (var (table, column, sequence) in keys) + { + // Identifiers, which no parameter can carry — and every one of them read off this + // application's own model, not from input. + var create = $"CREATE SEQUENCE \"{sequence.Name}\" START WITH {sequence.StartValue} " + + $"INCREMENT BY {sequence.IncrementBy} NO CYCLE"; + + // is_called = false, so the next nextval returns exactly this value. + var align = $"SELECT setval('\"{sequence.Name}\"', " + + $"GREATEST((SELECT MAX(\"{column}\") FROM \"{table}\"), {sequence.StartValue - 1}) + 1, false)"; + + await db.Database.ExecuteSqlRawAsync(create); + await db.Database.ExecuteSqlRawAsync(align); + } + } + /// The table a CREATE TABLE or CREATE INDEX … ON statement is about. private static string? CreatedTable(string statement) { diff --git a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs index 3e3e6e9..3d309c5 100644 --- a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs @@ -1,4 +1,5 @@ using DotNetBoostSample.Api.Authentication; +using DotNetBoostSample.Domain.Catalog; using System.Net; using System.Text.Json; using Xunit; @@ -7,15 +8,18 @@ namespace DotNetBoostSample.Tests.Integration; /// /// What the fourth library actually does here, over HTTP: a row's whole life in the trail, the name -/// of whoever changed it, the entry that outlives the row, and the two places this application's -/// arrangement shows through — translated text that cannot be in a trail, and a trail that is only -/// as wide as the slices that write it. +/// of whoever changed it, the entry that outlives the row, and the places this application's +/// arrangement shows through — translated text that cannot be in a trail, a sibling library's row +/// that is kept out of it, and the text a creation must not lose on the way in. /// /// Why these need the database. Every claim below is a claim about a transaction. /// That an entry is committed with the change it describes, that a refused save takes its entry -/// with it, that a deleted product leaves its history standing — none of it is visible to a test -/// that reads attributes, and the last two are exactly where a hand-written recording path would -/// go wrong without saying so. +/// with it, that a deleted product leaves its history standing, that a creation's entry is written +/// without a second save — none of it is visible to a test that reads attributes. +/// +/// Nothing in the catalogue's handlers records anything: every entry below is written by +/// DotNetBoost.Auditing's SaveChanges interceptor, reading the change tracker. The second test +/// makes that point without the handlers at all. /// /// Each test creates its own product. The tests in a collection share one database and the /// trail is append-only, so a test that read "the newest entry" of a shared row would be reading @@ -68,6 +72,172 @@ public async Task A_products_whole_life_is_in_the_trail_and_outlives_it() Assert.All(entries, entry => Assert.Equal(id.ToString(), entry.Str("entityId"))); } + /// + /// The same three entries from a context nobody wrote a line of auditing for: a category added, + /// changed and removed straight through AppDbContext, with no slice in the way. + /// + /// Which is the claim the interceptor makes — every change this context saves — and the + /// one the hand-written path this application used to have could not: a write that did not go + /// through one of the six slices recorded nothing. There is no request here, so there is no + /// actor either, and the entries say so rather than inventing one. + /// + [RequiresDockerFact] + public async Task A_change_saved_straight_through_the_context_is_recorded_by_the_interceptor() + { + var category = new Category { Slug = $"direct-{Guid.NewGuid():N}"[..20] }; + + await app.WithDbAsync(async db => + { + // AddAsync assigns the HiLo id, so it is known before the save — see CategoryConfiguration. + await db.Categories.AddAsync(category); + Assert.True(category.Id > 0); + await db.SaveChangesAsync(); + + category.Slug += "-renamed"; + await db.SaveChangesAsync(); + + db.Categories.Remove(category); + await db.SaveChangesAsync(); + }); + + var entries = await Trail(app.Client.AsActor(), "Category", category.Id); + + Assert.Equal(["Deleted", "Updated", "Created"], entries.Select(e => e.Str("action"))); + Assert.Equal(["Slug"], Changes(entries[1]).Keys); + Assert.Equal(category.Slug, Changes(entries[1])["Slug"].Str("newValue")); + Assert.All(entries, entry => Assert.True( + !entry.TryGetProperty("actor", out var actor) || actor.ValueKind == JsonValueKind.Null)); + } + + /// + /// A product saved with text in three languages: one save, one Created entry — and none + /// for the translation rows saved beside it. + /// + /// Translation is DotNetBoost.MultiLingual's entity, keyed on four columns, and an + /// audit entry records one. Before IgnoreEntity<Translation>() the interceptor + /// threw on exactly this save, which is every catalogue write; now the rows are skipped as + /// ignored. Asked of the table rather than of one product's trail, so a translation recorded + /// under any address at all — the seed's included — fails it. + /// + [RequiresDockerFact] + public async Task Translation_rows_saved_with_a_product_are_not_in_the_trail() + { + var client = app.Client.AsActor(); + + var created = await client.SendJsonCheckedAsync(HttpMethod.Post, "/api/products", new + { + sku = $"TRL-{Guid.NewGuid():N}"[..12], + price = 5.0m, + categoryId = app.SeededCategoryId, + translations = new Dictionary> + { + ["en"] = new() { ["Name"] = "Three languages", ["Description"] = "In English." }, + ["ar"] = new() { ["Name"] = "ثلاث لغات", ["Description"] = "بالعربية." }, + ["fr"] = new() { ["Name"] = "Trois langues", ["Description"] = "En français." } + } + }); + + var id = created.Int("id"); + + // The translations were saved — this is not a test that passes because nothing was written. + Assert.NotEqual("0", await app.ScalarAsync( + $"SELECT COUNT(*) FROM \"Translations\" WHERE \"EntityName\" = 'Product' AND \"EntityId\" = '{id}'")); + + Assert.Equal("0", await app.ScalarAsync( + "SELECT COUNT(*) FROM \"AuditEntries\" WHERE \"EntityName\" = 'Translation'")); + + Assert.Empty((await client.GetJsonAsync("/api/audit?entityName=Translation")) + .GetProperty("entries").EnumerateArray()); + + Assert.Equal("Created", Assert.Single(await Trail(client, "Product", id)).Str("action")); + } + + /// + /// A creation keeps the text it was created with, in every language — read back after the + /// save that wrote its Created entry. + /// + /// What it guards is the one place the two libraries' interceptors could step on each + /// other. A product created through the API never has its text assigned to the instance — it + /// goes to translation rows — so any later save that re-reads the instance as the request's + /// language's text writes "" over them. That is how this application once lost a name, + /// to a second save of its own; a key generated by the INSERT would give the audit interceptor + /// a reason to make one. HiLo keys keep a creation to one save, and this reads every language + /// back after it. + /// + /// Posted in a language, as the dashboard does: with no language resolved, MultiLingual + /// has no request language to file an instance's text under, and the case cannot arise. + /// + [RequiresDockerFact] + public async Task A_creation_keeps_its_text_in_every_language() + { + var client = app.Client.AsActor(); + + var created = await client.SendJsonCheckedAsync(HttpMethod.Post, "/api/products", new + { + sku = $"KEEP-{Guid.NewGuid():N}"[..12], + price = 3.0m, + categoryId = app.SeededCategoryId, + translations = new Dictionary> + { + ["en"] = new() { ["Name"] = "Kept in English", ["Description"] = "Still here." }, + ["ar"] = new() { ["Name"] = "محفوظ بالعربية", ["Description"] = "ما زال هنا." } + } + }, language: "en"); + + var id = created.Int("id"); + + // Every language at once, from the detail response's per-language map — the stored rows, + // not a fallback. + var text = (await client.GetJsonAsync($"/api/products/{id}")).GetProperty("translations"); + + Assert.Equal("Kept in English", text.GetProperty("en").Str("Name")); + Assert.Equal("Still here.", text.GetProperty("en").Str("Description")); + Assert.Equal("محفوظ بالعربية", text.GetProperty("ar").Str("Name")); + + // And as a plain read in the request's language, which is the path the interceptors serve. + Assert.Equal( + "محفوظ بالعربية", + (await client.GetJsonAsync("/api/products?pageSize=200", "ar")) + .GetProperty("items").EnumerateArray().Single(p => p.Int("id") == id).Str("name")); + + // And the entry the creation was recorded with is there too — the text survived the save + // that wrote it, not a save that skipped it. + Assert.Equal("Created", Assert.Single(await Trail(client, "Product", id)).Str("action")); + } + + /// + /// A sibling library's own row is in the trail as well: a settings write, recorded as an + /// update to DotNetBoost.Settings' Setting, by whoever made it. + /// + /// Because the capture records every entity the context saves unless it is ignored, and + /// only Translation is. Asserted rather than left as a sentence in the README, because + /// "one DbContext, so everything is audited" is exactly what a reader will assume — and here, + /// unlike before, it is true. + /// + [RequiresDockerFact] + public async Task A_settings_change_is_in_the_trail_too() + { + var client = app.Client.AsActor("settings.editor@example.com"); + + try + { + // Twice, because the first write of a group may be the one that CREATES its rows; the + // second can only be an update, and of the one value that moved. + await client.PostAsJsonChecked("/api/settings/catalog", new { pageSize = 17, currencySymbol = "$" }); + await client.PostAsJsonChecked("/api/settings/catalog", new { pageSize = 18, currencySymbol = "$" }); + + var newest = (await client.GetJsonAsync("/api/audit?entityName=Setting")) + .GetProperty("entries").EnumerateArray().First(); + + Assert.Equal("Updated", newest.Str("action")); + Assert.Equal("settings.editor@example.com", newest.Str("actor")); + } + finally + { + await app.ResetCatalogSettingsAsync(); + } + } + /// /// GET /api/audit/{id} — one entry, read by the id the page carries, with its changes. /// @@ -172,17 +342,13 @@ public async Task Translated_text_is_out_of_the_trail_rather_than_in_it_without_ } /// - /// A refused save takes its entry with it — the atomicity the explicit recording path was - /// arranged to keep. - /// - /// The update is recorded before the save, and the EF Core store tracks the - /// entry rather than writing it, so both are in one SaveChanges. Here that save is - /// refused by the unique index on the SKU: the product keeps its old SKU, the caller gets a - /// 409, and the trail must not have gained an entry claiming a change that never - /// happened. + /// A refused save takes its entry with it — the atomicity the interceptor exists to give. /// - /// This is the test that would fail if someone "simplified" the slices by recording after - /// the save instead of before it, and nothing else in the suite would notice. + /// The interceptor records the update while the save is being prepared, and the EF Core + /// store tracks the entry rather than writing it, so both are in one SaveChanges. Here + /// that save is refused by the unique index on the SKU: the product keeps its old SKU, the + /// caller gets a 409, and the trail must not have gained an entry claiming a change that + /// never happened. /// [RequiresDockerFact] public async Task A_refused_write_records_nothing() @@ -240,12 +406,11 @@ public async Task A_caller_who_names_nobody_is_the_default_sample_user() /// GET /api/audit/entities — the two entities this application declares, and no library's /// own. /// - /// Which is the honest shape of this installation, and worth asserting rather than - /// explaining: Setting, MediaFile and Translation are rows in the same - /// database, and they are not in the trail, because the change-tracker capture that - /// would have recorded them cannot be attached to a context that holds a composite-key entity. - /// A reader who assumes "one DbContext, so everything is audited" has it wrong, and this is - /// where they find out. + /// Declared is not the same as recorded, and this is where the difference shows. The + /// list is what DeclareEntities named, so it is the application's own two; the trail is + /// wider — Setting, MediaFile and the rest are recorded too, by the interceptor, + /// as shows. And Translation is + /// neither: ignored, so it cannot be declared, which Build() enforces. /// /// It also publishes maxPageSize, so a client can stop asking for more than it /// will ever be given rather than discovering the clamp by comparing take with what it diff --git a/tests/DotNetBoostSample.Tests/Integration/SampleApp.cs b/tests/DotNetBoostSample.Tests/Integration/SampleApp.cs index a17094d..b0e3687 100644 --- a/tests/DotNetBoostSample.Tests/Integration/SampleApp.cs +++ b/tests/DotNetBoostSample.Tests/Integration/SampleApp.cs @@ -60,9 +60,9 @@ public class SampleApp : IAsyncLifetime /// /// Asked for rather than written down as 1, which is what nine of these tests /// did. That literal is not wrong today — every fixture gets a fresh container and the seed - /// creates its categories first, so the first identity id is 1 and belongs to furniture. It is - /// an undeclared dependency on both of those facts, and it fails badly in either direction: - /// if no category has id 1 any more, a test about Arabic text answers 404 on its product + /// creates its categories first, so the first id the sequence hands out is 1 and belongs to + /// furniture. It is an undeclared dependency on both of those facts, and it fails badly in + /// either direction: if no category has id 1 any more, a test about Arabic text answers 404 on its product /// create and names the seed nowhere; and if id 1 is simply a DIFFERENT category — one added /// above furniture in DatabaseInitialiser — nothing fails at all, the products are /// just filed somewhere nobody meant, which is the worse of the two. @@ -171,6 +171,30 @@ await WithDbAsync(async db => return value; } + /// + /// A connection string for another database in this run's PostgreSQL — for the one test that + /// needs a database of its own to change the schema of, without touching the one every other + /// test in the collection shares. + /// + public string ConnectionStringFor(string database) => + new Npgsql.NpgsqlConnectionStringBuilder(_postgres.GetConnectionString()) { Database = database } + .ConnectionString; + + /// + /// A second copy of the real application, on and this run's + /// media root. Booted the same way as boots the shared one, so the + /// initialiser it runs is the one aspire run runs. The caller disposes it. + /// + public WebApplicationFactory Boot(string connectionString) => + new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:catalog", connectionString); + builder.UseSetting("Media:Root", _mediaRoot); + + if (BaseLanguage is not null) + builder.UseSetting("MultiLingual:BaseLanguage", BaseLanguage); + }); + /// Resets the catalogue settings group, so one test's edit cannot reach another. public Task ResetCatalogSettingsAsync() => Client.PostAsJsonChecked("/api/settings/catalog", new diff --git a/tests/DotNetBoostSample.Tests/Integration/SchemaUpgradeTests.cs b/tests/DotNetBoostSample.Tests/Integration/SchemaUpgradeTests.cs new file mode 100644 index 0000000..d2c82e9 --- /dev/null +++ b/tests/DotNetBoostSample.Tests/Integration/SchemaUpgradeTests.cs @@ -0,0 +1,92 @@ +using Npgsql; +using Xunit; + +namespace DotNetBoostSample.Tests.Integration; + +/// +/// A database made before Category and Product took their ids from HiLo sequences still works: +/// the initialiser adds the sequences, starting past the rows already there. +/// +/// Why this is a test and not a note. Aspire keeps the sample's PostgreSQL on a data +/// volume across runs, and EnsureCreated does nothing to a database that exists — so every +/// volume made before this change has identity columns and no sequences. Without the upgrade the +/// first create on it fails, because the sequence EF asks for a block of ids does not exist; with +/// an upgrade that started the sequences at 1, it fails a different way, because the first ids +/// handed out are rows the table already has. Each fixture starts from a fresh container, so +/// nothing else in the suite ever sees an old database. +/// +/// The old database is made rather than kept: the application is booted once on an empty +/// database, which creates and seeds it, and then the sequences are dropped and the identity +/// columns put back — the schema an older build would have left behind, rows included. +/// +[Collection(SampleAppCollection.Name)] +public sealed class SchemaUpgradeTests(SampleApp app) +{ + [RequiresDockerFact] + public async Task A_database_from_before_the_HiLo_keys_gets_its_sequences_past_the_existing_ids() + { + var database = $"upgrade_{Guid.NewGuid():N}"[..20]; + await app.ScalarAsync($"CREATE DATABASE \"{database}\""); + + var connectionString = app.ConnectionStringFor(database); + + // Created and seeded by the current build. + await using (var current = app.Boot(connectionString)) + current.CreateClient(); + + // Turned back into what an older build left: identity columns, no sequences, and the rows. + await ExecuteAsync(connectionString, """ + DROP SEQUENCE "CategoryIds"; + DROP SEQUENCE "ProductIds"; + ALTER TABLE "Categories" ALTER COLUMN "Id" ADD GENERATED BY DEFAULT AS IDENTITY; + ALTER TABLE "Products" ALTER COLUMN "Id" ADD GENERATED BY DEFAULT AS IDENTITY; + """); + + var highestProduct = int.Parse(await ScalarAsync(connectionString, """SELECT MAX("Id") FROM "Products" """)); + var highestCategory = int.Parse(await ScalarAsync(connectionString, """SELECT MAX("Id") FROM "Categories" """)); + + await using var upgraded = app.Boot(connectionString); + var client = upgraded.CreateClient().AsActor(); + + Assert.Equal("2", await ScalarAsync(connectionString, + """SELECT COUNT(*) FROM information_schema.sequences WHERE sequence_name IN ('CategoryIds', 'ProductIds')""")); + + var category = (await client.GetJsonAsync("/api/categories")) + .EnumerateArray().First(c => c.Str("slug") == "furniture").Int("id"); + + // The first id handed out is past every existing one, so the insert is not refused. + var id = await client.CreateProductAsync(category, "Made after the upgrade"); + + Assert.True(id > highestProduct, $"Product {id} was given an id at or below {highestProduct}."); + Assert.Equal( + "Made after the upgrade", + (await client.GetJsonAsync($"/api/products/{id}")).GetProperty("translations").GetProperty("en").Str("Name")); + + var created = (await client.SendJsonCheckedAsync(HttpMethod.Post, "/api/categories", new + { + slug = $"after-upgrade-{Guid.NewGuid():N}"[..24], + translations = new Dictionary> + { + ["en"] = new() { ["Name"] = "After the upgrade", ["Description"] = "A new category." } + } + })).Int("id"); + + Assert.True(created > highestCategory, $"Category {created} was given an id at or below {highestCategory}."); + } + + private static async Task ExecuteAsync(string connectionString, string sql) + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } + + private static async Task ScalarAsync(string connectionString, string sql) + { + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(sql, connection); + return (await command.ExecuteScalarAsync())?.ToString() ?? ""; + } +} From 6631754938f09a703b84fe7e995aa17ed51e9109 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Tue, 27 Oct 2026 00:02:35 +0300 Subject: [PATCH 12/44] Record a settings save as one audit entry for the group, not one per row DotNetBoost.Settings stores a group one row per property, so the audit interceptor recorded a settings save as a separate Setting entry per value that moved, each addressed by a row id. Setting is now ignored by the capture, and SettingGroupAuditObserver - an ISettingWriteObserver - records each save as one SettingGroup entry, keyed by the group's name, holding every value that changed. The entry is written after the settings save has committed, so it is the one entry in the trail that does not commit with its change. The README, Program.cs and ARCHITECTURE-SLICES.md say so and carry the new counts. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE-SLICES.md | 2 +- README.md | 84 ++++++++++++++----- src/DotNetBoostSample.Api/Program.cs | 30 ++++--- .../DependencyInjection.cs | 31 +++++-- .../Persistence/SettingGroupAuditObserver.cs | 59 +++++++++++++ .../Integration/AuditTrailTests.cs | 56 +++++++++---- 6 files changed, 204 insertions(+), 58 deletions(-) create mode 100644 src/DotNetBoostSample.Infrastructure/Persistence/SettingGroupAuditObserver.cs diff --git a/ARCHITECTURE-SLICES.md b/ARCHITECTURE-SLICES.md index e673ec5..06aaef8 100644 --- a/ARCHITECTURE-SLICES.md +++ b/ARCHITECTURE-SLICES.md @@ -56,7 +56,7 @@ compile against DotNetBoost's own `net8.0` TFM. This repo was **1,788 lines of C# in 13 files** when this was written, and the README's thesis is that the integration between three independent libraries is *five files, none of them large*. A -fourth library has since been installed, and that count is now eight files — see the README; the +fourth library has since been installed, and that count is now nine files — see the README; the reasoning below is unaffected. The one new file it would have changed its mind about, `Composition/CreationTrail.cs` — a creation's audit entry, written by hand in a scope of its own — is gone again: DotNetBoost.Auditing 1.0.0-preview.2 added `IgnoreEntity()`, its capture is now diff --git a/README.md b/README.md index cba25f1..044447e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ What it is built to demonstrate is as much what is *absent* as what is present: - no integration package pairing any two libraries Each library is installed on its own and configured on its own terms. Everything that joins them -is this application's code, in eight files — counted honestly below. `tests/` asserts that none of +is this application's code, in nine files — counted honestly below. `tests/` asserts that none of them references another, so the claim cannot quietly stop being true. **Auditing was added last, to a solution the other three were already in**, which is the one measure @@ -196,7 +196,7 @@ The filters — entity, key, actor, action and a date range — are parameters o asked, never a pass over the rows already on screen, which for an append-only trail would search the wrong set. The entity list comes from `GET /api/audit/entities`, so it is this installation's own two declared entities and not a list written into the client — the trail itself is wider, and the -other libraries' rows (`Setting`, `MediaFile`, …) are under *Any* — and the page size is bounded by +other libraries' entries (`SettingGroup`, `MediaFile`, …) are under *Any* — and the page size is bounded by the `maxPageSize` the same response publishes rather than by a number the dashboard would have to keep in step by hand. Paging is **newer and older** rather than a page count: the walk follows the `nextCursor` the API @@ -294,10 +294,10 @@ are `npm run lint`, `npm run typecheck` and `npm test`, and they are not part of ## Where the composition happens -Eight files, all of them this application's own code, none of them large. Each is a place where it +Nine files, all of them this application's own code, none of them large. Each is a place where it says two libraries are talking about the same thing — which is the only way they ever -find out, since none of them references another — except the last two, which are where it says one -library's data has no place in another's: +find out, since none of them references another — except the last three, which are where it says one +library's data has no place in another's, or not in the shape it is stored in: | | File | Joins | What it says | |---|---|---|---| @@ -308,16 +308,17 @@ library's data has no place in another's: | 5 | `Application/Settings/BrandingSettings.cs` | Settings × Media × MultiLingual | branding's name, its other languages and its logo are one thing | | 6 | `Infrastructure/DependencyInjection.cs` — one line | Auditing → MultiLingual | MultiLingual's translation row is not in the trail: `.IgnoreEntity()` | | 7 | `Domain/Catalog/Category.cs` and `Product.cs` | MultiLingual → Auditing | a translated property is not in the trail: `[AuditIgnore]` beside `[MultiLingual]` | +| 8 | `Infrastructure/Persistence/SettingGroupAuditObserver.cs` | Settings → Auditing | a settings save is one entry for the group, not one per row: an `ISettingWriteObserver`, beside `.IgnoreEntity()` | -Eight files and seven entries, because the seventh is one sentence said twice, once per entity. +Nine files and eight entries, because the seventh is one sentence said twice, once per entity. Counted this way because every file that joins two libraries is on the list — it was five before Auditing arrived, and the README said then that a sixth would mean the claim had quietly stopped -being true. It has: two of these exist because two libraries **disagree**, which is a worse kind of +being true. It has: three of these exist because two libraries **disagree**, which is a worse kind of glue than "these two tables are about one thing" and is the reason the count is worth keeping honest rather than tidy. -**And grep cannot check it, in either direction.** Eight files under `src/` carry `using` -directives for two or more `DotNetBoost.*` libraries, and six of them are above. `Program.cs` names +**And grep cannot check it, in either direction.** Nine files under `src/` carry `using` +directives for two or more `DotNetBoost.*` libraries, and seven of them are above. `Program.cs` names three and `Infrastructure/DependencyInjection.cs` all four while registering them in independent calls, which is the thing this sample is arguing for rather than a join — the second is on the list for one line of its three hundred, not for the four blocks around it; `ListProducts.cs` consumes two @@ -330,13 +331,14 @@ library's own deleted-files handler. A join is the identity assertion — this f not** an audit subject — and the assertion is usually a string, an attribute or a type both sides agree on, which is why `BrandingOwnerTests`, `KeyFormatAgreementTests` and `AuditRuleTests` exist. -**And note where six of the eight sit.** `MediaText`, `MediaTextCleanup`, +**And note where six of the nine sit.** `MediaText`, `MediaTextCleanup`, `MultiLingualSchemaContributor`, `BrandingProjector` and the two entities name `DotNetBoost.*.Core` and nothing else — no EF Core, no ASP.NET, no provider — so they live in the application and domain -layers, beside the code that uses them. Only `AppDbContext` and the `IgnoreEntity` line are -infrastructure, and both are there because four libraries share **one `DbContext`**. That split is -not a filing convention; it is the finding. Joining two libraries is not a persistence concern. -Joining them **in one database** is, and that is two files. +layers, beside the code that uses them. Only `AppDbContext`, the `IgnoreEntity` line and +`SettingGroupAuditObserver` are infrastructure, and all three are there because four libraries share +**one `DbContext`** — the observer saves its entry through it, because that is where the trail is +stored. That split is not a filing convention; it is the finding. Joining two libraries is not a +persistence concern. Joining them **in one database** is, and that is three files. ### 1. One database, four libraries — `src/DotNetBoostSample.Infrastructure/Persistence/AppDbContext.cs` @@ -675,10 +677,15 @@ on an identity column, so this is the avoidance of an ordering dependency rather failure it can reproduce. **And what the trail is now, which is the half a reader will assume.** Everything this `DbContext` -saves, except `Translation`. `Category` and `Product`, and `Setting`, `MediaFile`, -`MediaAttachment` and `Language` too — a branding change is recorded, with who made it, like a price -change; and the seed's first run is in the trail, with no actor, because there is no request to -take one from. `GET /api/audit/entities` lists what is **declared**, which is still the application's +saves, except `Translation` and `Setting`. `Category` and `Product`, and `MediaFile`, +`MediaAttachment` and `Language` too; and the seed's first run is in the trail, with no actor, +because there is no request to take one from. A settings group is recorded too, but not by the +interceptor: DotNetBoost.Settings stores a group one row per property, so its rows are ignored and +`SettingGroupAuditObserver` records each save as **one** `SettingGroup` entry, named by the group, +holding every value that moved — a branding change is recorded, with who made it, like a price +change. That entry is the one exception to "a change and its entry commit together": the observer +runs after the settings save has committed, and DotNetBoost.Settings logs and swallows its +failures, so a failed record leaves the change without an entry. `GET /api/audit/entities` lists what is **declared**, which is still the application's own two: declaring a type is what lets the discovery endpoint describe its rules, not what gets it recorded. The dashboard's entity filter is drawn from that list, so the libraries' entries appear under *Any*. @@ -686,8 +693,8 @@ under *Any*. `AuditTrailTests` asserts each piece rather than leaving it as a sentence here: a category added, changed and removed straight through `AppDbContext` with no slice in the way, recorded all the same; no `Translation` entry anywhere after a product is saved in three languages; a creation's text read -back in every language after the save that recorded it; and a settings change in the trail under -the name of whoever made it. +back in every language after the save that recorded it; and a settings save in the trail as one +entry for its group, under the name of whoever made it, with no `Setting` row anywhere. ### 7. A translated property is not in the trail @@ -726,6 +733,39 @@ worth catching, because its only symptom is a trail that confidently says the wr Translated text has its own history to be given one day — in the `Translations` table, with a language on each row. This is the honest shape of that gap until it does. +### 8. A settings save is one entry, not one per row — `src/.../Infrastructure/Persistence/SettingGroupAuditObserver.cs` + +DotNetBoost.Settings stores a group one row per property, so the interceptor on its own recorded a +branding save as a `Setting` entry per value that moved, each addressed by a row id nobody reading +the trail knows. The settings library already knows where an edit begins and ends, and says so +through `ISettingWriteObserver`, once per save, with the whole group's diff: + +```csharp +// Settings block +.UseWriteObserver() + +// Auditing block +.IgnoreEntity() +.IgnoreEntity(); +``` + +The observer hands that diff to `IAuditManager.RecordAsync` as one `SettingGroup` entry addressed by +the group's name, with the settings library's correlation id, and saves it. Two things it has to do +itself. The EF Core store tracks an entry rather than saving it, and the save it would normally ride +along with has already committed, so the observer calls `SaveChangesAsync` for the entry alone. And +an entry addressed by name has no type for Auditing to read `[AuditRedact]` from, so a `[Sensitive]` +property — whose values DotNetBoost.Settings has already withheld — is marked redacted on the entry +by the observer. + +**What it costs.** This is the one entry in the trail that does not commit with its change. The +observer runs after the settings write, and DotNetBoost.Settings logs and swallows anything it +throws, so a failure to record leaves the change standing without an entry. For configuration +edited from a dashboard, a trail that says "catalog: page size 17 → 18, currency € → £" in one line +is worth that; anything that must not lose an entry should not be recorded this way. + +`AuditTrailTests` asserts the shape: two values saved at once are one entry holding both, under the +caller's name, and no `Setting` row is in the trail at all. + --- ## Two places a language can live @@ -1164,7 +1204,7 @@ That last row is the one place a dependency rule earns its keep. Reading line: `PostgresErrorTranslator` is the only file in the solution that names Npgsql types, and it matches index names that the EF configurations declare as constants a few files away. -### Why four projects for 44 files +### Why four projects for 45 files It is more structure than 1,800 lines of behaviour needs, and the trade is deliberate. What it buys is that the dependency rules above are enforced by the build rather than by a comment; what it costs @@ -1234,6 +1274,8 @@ src/DotNetBoostSample.Infrastructure/ net8.0;net10.0 — where the data goes the tables and sequences an older volume lacks Persistence/MultiLingualModelCacheKeyFactory.cs the model depends on BaseLanguage, so EF's cache key has to as well + Persistence/SettingGroupAuditObserver.cs one audit entry per settings save, for the + group rather than its rows DependencyInjection.cs AddInfrastructure() — four independent registrations, in four blocks, in any order; the two interceptors on one context; and the one line, diff --git a/src/DotNetBoostSample.Api/Program.cs b/src/DotNetBoostSample.Api/Program.cs index 670b2bf..71946ce 100644 --- a/src/DotNetBoostSample.Api/Program.cs +++ b/src/DotNetBoostSample.Api/Program.cs @@ -23,16 +23,16 @@ // // The fourth arrived without moving the other three. Auditing's whole presence in this file is // one delegate and one Map call; in the rest of the solution it is one Apply* line in -// AppDbContext, one block and one UseAuditing(sp) in AddInfrastructure, and two attributes in -// Domain — no handler mentions it. Nothing in the other three moved. That is the strongest +// AppDbContext, one block and one UseAuditing(sp) in AddInfrastructure, one settings observer +// beside them, and two attributes in Domain — no handler mentions it. Nothing in the other three moved. That is the strongest // evidence here for what "no shared kernel" is worth, because it is the only one measured by // ADDING a library rather than by reading the ones already in — and the last two items on that // list are also where the cost showed up, which is the rest of this comment. // -// What ties them together is eight files, all of them application and domain code, none of them +// What ties them together is nine files, all of them application and domain code, none of them // large, and each one a place where this application says two libraries are talking about the -// same thing — except the last two, which are where it says one of them must leave the other's -// data alone: +// same thing — except the last three, which are where it says one of them must leave the other's +// data alone, or take it in another shape: // // • Infrastructure/Persistence/AppDbContext.cs — all four. One database, four Apply* // calls, nothing else. @@ -50,7 +50,7 @@ // Answers one settings group with its text in the request's // language and its logo. // -// Auditing added two more, and they are the first on this list that exist because two libraries +// Auditing added three more, and they are the first on this list that exist because two libraries // DISAGREE rather than because they are talking about the same thing: // // • Infrastructure/DependencyInjection.cs — Auditing → MultiLingual, one line of it: @@ -65,17 +65,23 @@ // beside [MultiLingual] on the two translated properties: a // trail has no language, so a property whose value depends // on one cannot be stated in it. One sentence, said twice, -// which is why eight files are seven entries. +// which is why nine files are eight entries. +// • Infrastructure/Persistence/SettingGroupAuditObserver.cs — Settings → Auditing. Settings +// stores a group one row per property and the trail wants +// the save, so the rows are ignored (.IgnoreEntity()) +// and this ISettingWriteObserver records each save as one +// entry for the group — after the save commits, not with it. // -// Eight, counted the same way as five was. The README said then that a sixth would mean the claim -// had quietly stopped being true, and it has: two of these exist because the libraries disagree +// Nine, counted the same way as five was. The README said then that a sixth would mean the claim +// had quietly stopped being true, and it has: three of these exist because the libraries disagree // about what can be recorded, which is a worse kind of glue than "these two tables are about one // thing" and is worth saying out loud rather than absorbing into the count. // -// Six of the eight turn out to be abstraction-only — they name DotNetBoost.*.Core and nothing +// Six of the nine turn out to be abstraction-only — they name DotNetBoost.*.Core and nothing // else — which is why they sit in the application and domain layers beside the code that uses -// them. The other two — AppDbContext and that one line of AddInfrastructure — are infrastructure, -// and both are about sharing one DbContext. Joining two libraries is not a persistence concern; +// them. The other three — AppDbContext, that one line of AddInfrastructure and the settings +// observer, which saves its entry through AppDbContext — are infrastructure, and all three are +// about sharing one DbContext. Joining two libraries is not a persistence concern; // joining them in ONE DATABASE is. // // The count is eight rather than a rounder number because every file joining two libraries is on diff --git a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs index babd8f8..356d527 100644 --- a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs +++ b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs @@ -3,6 +3,7 @@ using DotNetBoost.MultiLingual.Core; using DotNetBoost.MultiLingual.Core.Models; using DotNetBoost.Settings.Core.Interfaces; +using DotNetBoost.Settings.Core.Models; using DotNetBoostSample.Application.Abstractions.Persistence; using DotNetBoostSample.Application.Composition; using DotNetBoostSample.Application.Settings; @@ -172,6 +173,13 @@ public static IServiceCollection AddInfrastructure( // libraries joined in one class, on the group's own endpoint, so there is one URL for // branding rather than two. .UseProjector() + // And the audit trail of a settings edit: one entry per group saved, with every value + // that moved, rather than one per row of the Settings table. The interceptor would + // record the rows — see the Auditing block, which ignores them for this — but a row is + // one property of one group under an id nobody reading the trail knows, and this + // library already knows where an edit begins and ends. SettingGroupAuditObserver has + // what that costs. + .UseWriteObserver() .Build(); // ── DotNetBoost.Media ──────────────────────────────────────────────── @@ -281,12 +289,17 @@ public static IServiceCollection AddInfrastructure( // goes into the caller's own save, and there is no second save to reason about. The // libraries' own entities already assign their keys in code. // - // What that buys is the guarantee the library is built around, now without exceptions: - // a change and its entry are in one SaveChanges, so they commit together or neither does. - // And every mapped entity is captured unless it is ignored — Setting, MediaFile, - // MediaAttachment and Language as well as the catalogue — because that is the library's - // default, and an edit to branding is as much a change somebody made as an edit to a - // product. + // 3. Settings. DotNetBoost.Settings stores a group as one row per property, so the + // capture recorded a settings save as a `Setting` entry per value that moved, each + // addressed by a row id. `IgnoreEntity()` below keeps those rows out, and + // SettingGroupAuditObserver — registered on the Settings block above — records the + // save instead, as one `SettingGroup` entry addressed by the group's name. + // + // What that buys is the guarantee the library is built around: a change and its entry are + // in one SaveChanges, so they commit together or neither does. The one exception is the + // settings entry, written after the settings save has committed — see the observer. And + // every other mapped entity is captured unless it is ignored — MediaFile, MediaAttachment + // and Language as well as the catalogue — because that is the library's default. var auditing = services .AddAuditing() // The trail goes in AppDbContext, not in a store of its own, and that is what the @@ -307,7 +320,11 @@ public static IServiceCollection AddInfrastructure( // DotNetBoost.MultiLingual's row, kept out of the trail — see above. Build() refuses a // type that is both declared and ignored, and the declaration above names only this // application's own two, so the two lines cannot disagree. - .IgnoreEntity(); + .IgnoreEntity() + // DotNetBoost.Settings' row, kept out for the same reason from the other direction: + // the observer above records a settings save as a whole group, and leaving the rows in + // would record it twice. + .IgnoreEntity(); // No retention is configured, deliberately. Retention does nothing until a prune runs, // this sample schedules none, and POST /api/audit/prune is not mapped — so a number here diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/SettingGroupAuditObserver.cs b/src/DotNetBoostSample.Infrastructure/Persistence/SettingGroupAuditObserver.cs new file mode 100644 index 0000000..70f1714 --- /dev/null +++ b/src/DotNetBoostSample.Infrastructure/Persistence/SettingGroupAuditObserver.cs @@ -0,0 +1,59 @@ +using DotNetBoost.Auditing.Core.Interfaces; +using DotNetBoost.Auditing.Core.Models; +using DotNetBoost.Settings.Core.Interfaces; + +namespace DotNetBoostSample.Infrastructure.Persistence; + +/// +/// Records one audit entry per save of a settings group, rather than one per row of the +/// Settings table. +/// +/// Why not the interceptor. DotNetBoost.Settings stores a group as one row per +/// property, so the interceptor — which sees rows — wrote a separate Setting entry for every +/// value that moved, addressed by a row id nobody reading the trail knows. The settings library +/// already knows where an edit begins and ends: is called once +/// per save, with the whole group's diff. So Setting is ignored by the capture, and this +/// writes the entry instead, addressed as plus the group's name. +/// +/// What it costs. The interceptor's guarantee — a change and its entry in one +/// SaveChanges — does not hold here. The observer runs after the settings write has +/// committed, and DotNetBoost.Settings logs and swallows anything it throws, so a failure to record +/// leaves the change standing without an entry. For configuration edited through a dashboard that +/// is the right trade for a readable trail; for anything stricter, it is not. +/// +/// Secrets. An entry addressed by name has no CLR type, so Auditing has no +/// [AuditRedact] rules to apply to it. DotNetBoost.Settings has already withheld the values +/// of every [Sensitive] property, and this marks those changes redacted on the entry, so the +/// trail says "changed, value withheld" rather than "changed from nothing to nothing". +/// +public sealed class SettingGroupAuditObserver(IAuditManager audit, AppDbContext db) : ISettingWriteObserver +{ + /// The entity name a settings group's entries are stored under. + public const string EntityName = "SettingGroup"; + + /// + public async Task OnWrittenAsync(SettingWrite write, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(write); + + var entry = await audit.RecordAsync(new AuditWrite + { + EntityName = EntityName, + EntityId = write.GroupName, + Action = write.Kind == SettingWriteKind.Cleared ? AuditAction.Deleted : AuditAction.Updated, + Changes = [.. write.Changes.Select(c => new AuditChange(c.PropertyName, c.OldValue, c.NewValue))], + // The settings library's id for the save, so the entry joins the request it came from. + CorrelationId = write.CorrelationId, + }, cancellationToken); + + if (entry is null) return; + + var redacted = write.Changes.Where(c => c.IsRedacted).Select(c => c.PropertyName).ToHashSet(); + foreach (var change in entry.Changes.Where(c => redacted.Contains(c.PropertyName))) + change.IsRedacted = true; + + // The EF Core store tracks the entry rather than saving it, and the save it would normally + // ride along with has already happened — so this one is the entry's own. + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs index 3d309c5..d887073 100644 --- a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs @@ -1,5 +1,7 @@ using DotNetBoostSample.Api.Authentication; +using DotNetBoostSample.Application.Settings; using DotNetBoostSample.Domain.Catalog; +using DotNetBoostSample.Infrastructure.Persistence; using System.Net; using System.Text.Json; using Xunit; @@ -9,8 +11,8 @@ namespace DotNetBoostSample.Tests.Integration; /// /// What the fourth library actually does here, over HTTP: a row's whole life in the trail, the name /// of whoever changed it, the entry that outlives the row, and the places this application's -/// arrangement shows through — translated text that cannot be in a trail, a sibling library's row -/// that is kept out of it, and the text a creation must not lose on the way in. +/// arrangement shows through — translated text that cannot be in a trail, two sibling libraries' +/// rows that are kept out of it, and the text a creation must not lose on the way in. /// /// Why these need the database. Every claim below is a claim about a transaction. /// That an entry is committed with the change it describes, that a refused save takes its entry @@ -18,7 +20,8 @@ namespace DotNetBoostSample.Tests.Integration; /// without a second save — none of it is visible to a test that reads attributes. /// /// Nothing in the catalogue's handlers records anything: every entry below is written by -/// DotNetBoost.Auditing's SaveChanges interceptor, reading the change tracker. The second test +/// DotNetBoost.Auditing's SaveChanges interceptor, reading the change tracker — except a settings +/// group's, which DotNetBoost.Settings reports a save of as a whole. The second test /// makes that point without the handlers at all. /// /// Each test creates its own product. The tests in a collection share one database and the @@ -206,31 +209,44 @@ public async Task A_creation_keeps_its_text_in_every_language() } /// - /// A sibling library's own row is in the trail as well: a settings write, recorded as an - /// update to DotNetBoost.Settings' Setting, by whoever made it. + /// A settings save is in the trail as ONE entry for the group, with every value that moved, + /// by whoever made it — and none of DotNetBoost.Settings' own Setting rows are. /// - /// Because the capture records every entity the context saves unless it is ignored, and - /// only Translation is. Asserted rather than left as a sentence in the README, because - /// "one DbContext, so everything is audited" is exactly what a reader will assume — and here, - /// unlike before, it is true. + /// The group is stored one row per property, so the interceptor alone would record a + /// save as an entry per row. IgnoreEntity<Setting>() keeps the rows out and + /// records the save, addressed as + /// ("SettingGroup", <group name>). Two values are changed at once here, because + /// one entry holding both is the whole point. /// [RequiresDockerFact] - public async Task A_settings_change_is_in_the_trail_too() + public async Task A_settings_save_is_one_entry_for_the_group() { var client = app.Client.AsActor("settings.editor@example.com"); + var settingRows = "SELECT COUNT(*) FROM \"AuditEntries\" WHERE \"EntityName\" = 'Setting'"; try { // Twice, because the first write of a group may be the one that CREATES its rows; the - // second can only be an update, and of the one value that moved. - await client.PostAsJsonChecked("/api/settings/catalog", new { pageSize = 17, currencySymbol = "$" }); - await client.PostAsJsonChecked("/api/settings/catalog", new { pageSize = 18, currencySymbol = "$" }); + // second can only be an update, and of the two values that moved. + await client.PostAsJsonChecked("/api/settings/catalog", new { pageSize = 17, currencySymbol = "€" }); + var before = await GroupTrail(client); - var newest = (await client.GetJsonAsync("/api/audit?entityName=Setting")) - .GetProperty("entries").EnumerateArray().First(); + await client.PostAsJsonChecked("/api/settings/catalog", new { pageSize = 18, currencySymbol = "£" }); + var after = await GroupTrail(client); + + Assert.Equal(before.Count + 1, after.Count); + + var newest = after[0]; Assert.Equal("Updated", newest.Str("action")); Assert.Equal("settings.editor@example.com", newest.Str("actor")); + Assert.Equal(["CurrencySymbol", "PageSize"], Changes(newest).Keys.Order()); + Assert.Equal(("17", "18"), (Changes(newest)["PageSize"].Str("oldValue"), Changes(newest)["PageSize"].Str("newValue"))); + Assert.Equal(("€", "£"), (Changes(newest)["CurrencySymbol"].Str("oldValue"), Changes(newest)["CurrencySymbol"].Str("newValue"))); + + // And the rows behind it are not in the trail at all — not from these saves, and not + // from the seed's. + Assert.Equal("0", await app.ScalarAsync(settingRows)); } finally { @@ -408,8 +424,8 @@ public async Task A_caller_who_names_nobody_is_the_default_sample_user() /// /// Declared is not the same as recorded, and this is where the difference shows. The /// list is what DeclareEntities named, so it is the application's own two; the trail is - /// wider — Setting, MediaFile and the rest are recorded too, by the interceptor, - /// as shows. And Translation is + /// wider — MediaFile and the rest are recorded too, by the interceptor, and a settings + /// group by its observer, as shows. And Translation is /// neither: ignored, so it cannot be declared, which Build() enforces. /// /// It also publishes maxPageSize, so a client can stop asking for more than it @@ -434,6 +450,12 @@ public async Task The_declared_entities_are_this_applications_own_two() // ── Reading the trail ──────────────────────────────────────────────────── + /// The catalog settings group's entries, newest first. + private static async Task> GroupTrail(HttpClient client) => + [.. (await client.GetJsonAsync( + $"/api/audit?entityName={SettingGroupAuditObserver.EntityName}&entityId={nameof(CatalogSettings)}")) + .GetProperty("entries").EnumerateArray()]; + /// One entity's entries, newest first. private static async Task> Trail(HttpClient client, string entityName, int entityId) => [.. (await client.GetJsonAsync($"/api/audit?entityName={entityName}&entityId={entityId}")) From 839f7af2dcc307704587e5a885efd33cb93b5e33 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Tue, 27 Oct 2026 00:08:36 +0300 Subject: [PATCH 13/44] Store a settings group's audit entries under the name Setting The group entries were named SettingGroup because IgnoreEntity() also suppresses a write addressed by the name "Setting" alone. They now use Setting, and the write carries the group's class (CatalogSettings, BrandingSettings) as its EntityType, found from the group's stored name the way DotNetBoost.Settings resolves it. Auditing checks an ignore by type when there is one, so the row entries stay out and the group's entry goes in; [AuditIgnore] and [AuditRedact] on a group's properties now apply too. The test now checks that every Setting entry is addressed by a group name rather than a row id. Co-Authored-By: Claude Opus 5.5 --- README.md | 25 ++++++----- .../DependencyInjection.cs | 4 +- .../Persistence/SettingGroupAuditObserver.cs | 42 +++++++++++++++---- .../Integration/AuditTrailTests.cs | 16 ++++--- 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 044447e..0bd0dde 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The filters — entity, key, actor, action and a date range — are parameters o asked, never a pass over the rows already on screen, which for an append-only trail would search the wrong set. The entity list comes from `GET /api/audit/entities`, so it is this installation's own two declared entities and not a list written into the client — the trail itself is wider, and the -other libraries' entries (`SettingGroup`, `MediaFile`, …) are under *Any* — and the page size is bounded by +other libraries' entries (`Setting`, `MediaFile`, …) are under *Any* — and the page size is bounded by the `maxPageSize` the same response publishes rather than by a number the dashboard would have to keep in step by hand. Paging is **newer and older** rather than a page count: the walk follows the `nextCursor` the API @@ -681,7 +681,7 @@ saves, except `Translation` and `Setting`. `Category` and `Product`, and `MediaF `MediaAttachment` and `Language` too; and the seed's first run is in the trail, with no actor, because there is no request to take one from. A settings group is recorded too, but not by the interceptor: DotNetBoost.Settings stores a group one row per property, so its rows are ignored and -`SettingGroupAuditObserver` records each save as **one** `SettingGroup` entry, named by the group, +`SettingGroupAuditObserver` records each save as **one** `Setting` entry, named by the group, holding every value that moved — a branding change is recorded, with who made it, like a price change. That entry is the one exception to "a change and its entry commit together": the observer runs after the settings save has committed, and DotNetBoost.Settings logs and swallows its @@ -694,7 +694,7 @@ under *Any*. changed and removed straight through `AppDbContext` with no slice in the way, recorded all the same; no `Translation` entry anywhere after a product is saved in three languages; a creation's text read back in every language after the save that recorded it; and a settings save in the trail as one -entry for its group, under the name of whoever made it, with no `Setting` row anywhere. +entry for its group, under the name of whoever made it, with no entry for a row anywhere. ### 7. A translated property is not in the trail @@ -749,13 +749,16 @@ through `ISettingWriteObserver`, once per save, with the whole group's diff: .IgnoreEntity(); ``` -The observer hands that diff to `IAuditManager.RecordAsync` as one `SettingGroup` entry addressed by -the group's name, with the settings library's correlation id, and saves it. Two things it has to do -itself. The EF Core store tracks an entry rather than saving it, and the save it would normally ride -along with has already committed, so the observer calls `SaveChangesAsync` for the entry alone. And -an entry addressed by name has no type for Auditing to read `[AuditRedact]` from, so a `[Sensitive]` -property — whose values DotNetBoost.Settings has already withheld — is marked redacted on the entry -by the observer. +The observer hands that diff to `IAuditManager.RecordAsync` as one `Setting` entry addressed by the +group's name, with the settings library's correlation id, and saves it. It is the name the ignore +names, and Auditing would drop a write addressed by that name alone — so the write also carries the +group's class, `CatalogSettings` say, as its type. An ignore is checked by type when there is one, +and that type is not ignored; it is also where `[AuditIgnore]` and `[AuditRedact]` on a group's +properties are read from. Two more things the observer has to do itself. The EF Core store tracks an +entry rather than saving it, and the save it would normally ride along with has already committed, +so the observer calls `SaveChangesAsync` for the entry alone. And a `[Sensitive]` property, whose values DotNetBoost.Settings has already withheld, is marked +redacted on the entry by the observer, so the trail does not read as "changed from nothing to +nothing". **What it costs.** This is the one entry in the trail that does not commit with its change. The observer runs after the settings write, and DotNetBoost.Settings logs and swallows anything it @@ -764,7 +767,7 @@ edited from a dashboard, a trail that says "catalog: page size 17 → 18, curren is worth that; anything that must not lose an entry should not be recorded this way. `AuditTrailTests` asserts the shape: two values saved at once are one entry holding both, under the -caller's name, and no `Setting` row is in the trail at all. +caller's name, and no `Setting` entry in the trail is addressed by a row rather than a group. --- diff --git a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs index 356d527..2813a4a 100644 --- a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs +++ b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs @@ -293,7 +293,9 @@ public static IServiceCollection AddInfrastructure( // capture recorded a settings save as a `Setting` entry per value that moved, each // addressed by a row id. `IgnoreEntity()` below keeps those rows out, and // SettingGroupAuditObserver — registered on the Settings block above — records the - // save instead, as one `SettingGroup` entry addressed by the group's name. + // save instead, as one `Setting` entry addressed by the group's name. It is still + // recorded under the ignored name because the write carries the group's class as its + // type, and an ignore is checked by type when there is one. // // What that buys is the guarantee the library is built around: a change and its entry are // in one SaveChanges, so they commit together or neither does. The one exception is the diff --git a/src/DotNetBoostSample.Infrastructure/Persistence/SettingGroupAuditObserver.cs b/src/DotNetBoostSample.Infrastructure/Persistence/SettingGroupAuditObserver.cs index 70f1714..0f8ad31 100644 --- a/src/DotNetBoostSample.Infrastructure/Persistence/SettingGroupAuditObserver.cs +++ b/src/DotNetBoostSample.Infrastructure/Persistence/SettingGroupAuditObserver.cs @@ -1,6 +1,9 @@ using DotNetBoost.Auditing.Core.Interfaces; using DotNetBoost.Auditing.Core.Models; +using DotNetBoost.Settings.Core.Attributes; using DotNetBoost.Settings.Core.Interfaces; +using DotNetBoostSample.Application.Settings; +using System.Reflection; namespace DotNetBoostSample.Infrastructure.Persistence; @@ -12,8 +15,16 @@ namespace DotNetBoostSample.Infrastructure.Persistence; /// property, so the interceptor — which sees rows — wrote a separate Setting entry for every /// value that moved, addressed by a row id nobody reading the trail knows. The settings library /// already knows where an edit begins and ends: is called once -/// per save, with the whole group's diff. So Setting is ignored by the capture, and this -/// writes the entry instead, addressed as plus the group's name. +/// per save, with the whole group's diff. So the Setting rows are ignored by the capture, +/// and this writes the entry instead, still under but addressed by the +/// group's name. +/// +/// Why the entry carries the group's type. IgnoreEntity<Setting>() also +/// suppresses a write addressed by the name "Setting" alone, so an entry with no type would +/// be dropped as ignored. Auditing checks the ignore by type when the write has one, and the type +/// this entry is about is the group's class — , say — which is not +/// ignored. It is also where the model's rules come from: an [AuditIgnore] or +/// [AuditRedact] on a group's property is honoured, exactly as on an entity. /// /// What it costs. The interceptor's guarantee — a change and its entry in one /// SaveChanges — does not hold here. The observer runs after the settings write has @@ -21,25 +32,42 @@ namespace DotNetBoostSample.Infrastructure.Persistence; /// leaves the change standing without an entry. For configuration edited through a dashboard that /// is the right trade for a readable trail; for anything stricter, it is not. /// -/// Secrets. An entry addressed by name has no CLR type, so Auditing has no -/// [AuditRedact] rules to apply to it. DotNetBoost.Settings has already withheld the values -/// of every [Sensitive] property, and this marks those changes redacted on the entry, so the -/// trail says "changed, value withheld" rather than "changed from nothing to nothing". +/// Secrets. DotNetBoost.Settings has already withheld the values of every +/// [Sensitive] property, and this marks those changes redacted on the entry, so the trail +/// says "changed, value withheld" rather than "changed from nothing to nothing". /// public sealed class SettingGroupAuditObserver(IAuditManager audit, AppDbContext db) : ISettingWriteObserver { /// The entity name a settings group's entries are stored under. - public const string EntityName = "SettingGroup"; + public const string EntityName = "Setting"; + + /// + /// Every settings group in the application assembly, by the name its rows are stored under — + /// the same assembly UseFluentValidation scans, resolved the way DotNetBoost.Settings + /// resolves it. + /// + private static readonly Dictionary Groups = typeof(BrandingSettings).Assembly + .GetTypes() + .Where(type => type.GetCustomAttribute() is not null) + .ToDictionary(SettingGroupAttribute.ResolveName, StringComparer.Ordinal); /// public async Task OnWrittenAsync(SettingWrite write, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(write); + // Thrown rather than recorded without a type, which would be dropped as ignored and say + // nothing. DotNetBoost.Settings logs it with the group's name. + if (!Groups.TryGetValue(write.GroupName, out var groupType)) + throw new InvalidOperationException( + $"Settings group '{write.GroupName}' has no [SettingGroup] class in " + + $"{typeof(BrandingSettings).Assembly.GetName().Name}, so its save cannot be audited."); + var entry = await audit.RecordAsync(new AuditWrite { EntityName = EntityName, EntityId = write.GroupName, + EntityType = groupType, Action = write.Kind == SettingWriteKind.Cleared ? AuditAction.Deleted : AuditAction.Updated, Changes = [.. write.Changes.Select(c => new AuditChange(c.PropertyName, c.OldValue, c.NewValue))], // The settings library's id for the save, so the entry joins the request it came from. diff --git a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs index d887073..be97d29 100644 --- a/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/AuditTrailTests.cs @@ -209,20 +209,24 @@ public async Task A_creation_keeps_its_text_in_every_language() } /// - /// A settings save is in the trail as ONE entry for the group, with every value that moved, - /// by whoever made it — and none of DotNetBoost.Settings' own Setting rows are. + /// A settings save is in the trail as ONE Setting entry for the group, with every value + /// that moved, by whoever made it — and none of DotNetBoost.Settings' own rows are. /// /// The group is stored one row per property, so the interceptor alone would record a /// save as an entry per row. IgnoreEntity<Setting>() keeps the rows out and /// records the save, addressed as - /// ("SettingGroup", <group name>). Two values are changed at once here, because - /// one entry holding both is the whole point. + /// ("Setting", <group name>) — under the ignored name, which is why the observer + /// gives the write the group's type. Two values are changed at once here, because one entry + /// holding both is the whole point. /// [RequiresDockerFact] public async Task A_settings_save_is_one_entry_for_the_group() { var client = app.Client.AsActor("settings.editor@example.com"); - var settingRows = "SELECT COUNT(*) FROM \"AuditEntries\" WHERE \"EntityName\" = 'Setting'"; + // A row's entry would be addressed by the row's id; a group's is addressed by its name. + var settingRows = + "SELECT COUNT(*) FROM \"AuditEntries\" WHERE \"EntityName\" = 'Setting' " + + $"AND \"EntityId\" NOT IN ('{nameof(CatalogSettings)}', '{nameof(BrandingSettings)}')"; try { @@ -245,7 +249,7 @@ public async Task A_settings_save_is_one_entry_for_the_group() Assert.Equal(("€", "£"), (Changes(newest)["CurrencySymbol"].Str("oldValue"), Changes(newest)["CurrencySymbol"].Str("newValue"))); // And the rows behind it are not in the trail at all — not from these saves, and not - // from the seed's. + // from the seed's: every Setting entry there is is a group's. Assert.Equal("0", await app.ScalarAsync(settingRows)); } finally From 816fb13499c37c6b13cb67770cedefc0319c9f34 Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Tue, 27 Oct 2026 00:34:56 +0300 Subject: [PATCH 14/44] Keep the audit trail for as long as a settings group says, and prune it in the host An `auditing` settings group (PruneEnabled, RetentionDays, PruneIntervalHours) holds the retention policy, and AuditRetentionService reads it every minute and calls PruneOlderThanAsync when a prune is due, so an edit applies without a restart. Off by default. Only retention, because every other AuditingOptions value is read once at Build(). The Audit page's rules card now reports the retention the service applies rather than "kept indefinitely". Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE-SLICES.md | 2 +- README.md | 46 ++++-- .../app/components/audit/EntityRules.vue | 26 ++- clients/dashboard/app/composables/useApi.ts | 3 +- .../app/composables/useAuditingSettings.ts | 33 ++++ clients/dashboard/app/types/index.d.ts | 7 +- clients/dashboard/app/utils/settings.ts | 1 + clients/dashboard/i18n/locales/ar.json | 19 +++ clients/dashboard/i18n/locales/en.json | 19 +++ clients/dashboard/i18n/locales/fr.json | 19 +++ .../Auditing/AuditRetentionService.cs | 116 +++++++++++++ src/DotNetBoostSample.Api/Program.cs | 19 ++- .../Settings/AuditingSettings.cs | 65 ++++++++ .../DependencyInjection.cs | 11 +- .../Integration/AuditRetentionTests.cs | 152 ++++++++++++++++++ .../Integration/OpenApiShapeTests.cs | 7 +- .../Integration/RemainingRouteTests.cs | 6 +- .../Integration/SampleApp.cs | 21 +++ .../Integration/SettingsSchemaTests.cs | 2 +- 19 files changed, 537 insertions(+), 37 deletions(-) create mode 100644 clients/dashboard/app/composables/useAuditingSettings.ts create mode 100644 src/DotNetBoostSample.Api/Auditing/AuditRetentionService.cs create mode 100644 src/DotNetBoostSample.Application/Settings/AuditingSettings.cs create mode 100644 tests/DotNetBoostSample.Tests/Integration/AuditRetentionTests.cs diff --git a/ARCHITECTURE-SLICES.md b/ARCHITECTURE-SLICES.md index 06aaef8..15d787c 100644 --- a/ARCHITECTURE-SLICES.md +++ b/ARCHITECTURE-SLICES.md @@ -5,7 +5,7 @@ the two disagreed the code won — §10 lists what changed on contact with the c written while the work sat on `feat/vertical-slices-cqrs` and still argues with itself about whether to merge it; §11.3 says how that ended. -The solution builds clean on `net8.0` and `net10.0` with `TreatWarningsAsErrors`, and all 148 tests +The solution builds clean on `net8.0` and `net10.0` with `TreatWarningsAsErrors`, and all 154 tests pass, including the integration suite that boots the real `Program.cs` against a throwaway PostgreSQL and drives every route over HTTP. No endpoint's URL, status code or JSON shape changed. diff --git a/README.md b/README.md index 0bd0dde..cc06231 100644 --- a/README.md +++ b/README.md @@ -317,13 +317,14 @@ being true. It has: three of these exist because two libraries **disagree**, whi glue than "these two tables are about one thing" and is the reason the count is worth keeping honest rather than tidy. -**And grep cannot check it, in either direction.** Nine files under `src/` carry `using` +**And grep cannot check it, in either direction.** Ten files under `src/` carry `using` directives for two or more `DotNetBoost.*` libraries, and seven of them are above. `Program.cs` names three and `Infrastructure/DependencyInjection.cs` all four while registering them in independent calls, which is the thing this sample is arguing for rather than a join — the second is on the list for one line of its three hundred, not for the four blocks around it; `ListProducts.cs` consumes two -side by side — a page size from Settings and the files from Media — without ever saying that one -library's subject is the other's. Meanwhile two files that *are* above name one library each: +side by side — a page size from Settings and the files from Media — and +`Api/Auditing/AuditRetentionService.cs` does the same with a retention from Settings and a prune +from Auditing, neither ever saying that one library's subject is the other's. Meanwhile two files that *are* above name one library each: `MediaText` references only MultiLingual, because what joins it to Media is the subject name `"MediaFile"` and not a type; and `MediaTextCleanup` references only Media, because it is that library's own deleted-files handler. A join is the identity assertion — this file's alt text **is** that translation subject, this settings property @@ -867,11 +868,11 @@ do. `dotnet test` boots the sample twice — two hosts, two schemas, one run. That is what `MultiLingualModelCacheKeyFactory` is for, and it is the only reason it exists. -**The whole integration suite runs in both modes**: fifty-one tests against each, the same -assertions, nothing adapted — and between them they call **every one of the API's 45 published +**The whole integration suite runs in both modes**: fifty-four tests against each, the same +assertions, nothing adapted — and between them they call **every one of the API's 48 published routes**, found by listing the generated document and crossing it against what the tests request. -That inventory is now one of the fifty-one rather than a cross-check someone did once: -`OpenApiShapeTests` asserts the forty-five by name, so a route mapped by this application or by the +That inventory is now one of the fifty-four rather than a cross-check someone did once: +`OpenApiShapeTests` asserts the forty-eight by name, so a route mapped by this application or by the next version of a library fails the suite instead of quietly going uncalled — which is exactly how the four `/api/audit` routes announced themselves. The rest were not written for this mode and were not changed for it, which is what makes them worth re-running — branding, media shapes, settings, @@ -1013,13 +1014,13 @@ can tell. - **`OpenApiShapeTests`** — the generated document describes the shape each endpoint actually answers with, which is not free: a route returning `IResult` publishes nothing, so the nine refusable ones declare `.Produces()` and the two listings return their value directly. It also - holds the route inventory — all forty-five, by method and path — which is what keeps the claim + holds the route inventory — all forty-eight, by method and path — which is what keeps the claim above from going stale when a library adds an endpoint. - **`BaseLanguageTests`** — what only base-language mode can be asked, which is everything the classes above cannot express because they have to pass in both: that the `Name` column exists and holds English (read with raw SQL — in the default mode that statement would not even parse), that a create under `Accept-Language: ar` is allowed at all, that an update in Arabic leaves the base - text alone, that the base language cannot be deleted. Fifteen tests, beside the fifty-one the + text alone, that the base language cannot be deleted. Fifteen tests, beside the fifty-four the suite runs in each mode. See [Two places a language can live](#two-places-a-language-can-live). - **`RemainingRouteTests`** — the six routes nothing else called, found by crossing the OpenAPI document against what the suite requests: promoting a language to default (allowed with a base @@ -1039,6 +1040,11 @@ can tell. `Ignore`; and the two declared entities being this application's own. One of them earns its keep on its own: **a refused write records nothing** — a `409` from the unique index on the SKU takes the entry with it, because the interceptor tracked the entry inside the save that was refused. +- **`AuditRetentionTests`** — the `auditing` settings group starts switched off and publishes the + bounds its validator enforces; an entry three days old survives while it is off; and once it is + on with a retention of one day, `AuditRetentionService` deletes that entry and keeps one written + now — with the save that turned it on in the trail, under the caller who made it. The prune is + the service's own method on the instance the host runs, not a copy of its logic. - **`SchemaUpgradeTests`** — a database from before the HiLo keys, identity columns and rows included, booted on: the initialiser adds the two sequences past the ids already there, and the first product and category created on it get ids nobody holds. @@ -1047,7 +1053,7 @@ Both regression tests were checked the only way that means anything: the fix was failed, the fix was put back. **Without a container runtime these skip rather than fail** — `dotnet test` still runs the 30 fast -tests and reports the other 118 as skipped, with the reason. The README already asks for Docker, +tests and reports the other 124 as skipped, with the reason. The README already asks for Docker, OrbStack or Podman to run the sample at all, so this adds no prerequisite. ### And the dashboard's own @@ -1207,7 +1213,7 @@ That last row is the one place a dependency rule earns its keep. Reading line: `PostgresErrorTranslator` is the only file in the solution that names Npgsql types, and it matches index names that the EF configurations declare as constants a few files away. -### Why four projects for 45 files +### Why four projects for 47 files It is more structure than 1,800 lines of behaviour needs, and the trade is deliberate. What it buys is that the dependency rules above are enforced by the build rather than by a comment; what it costs @@ -1554,10 +1560,20 @@ DotNetBoost.Auditing maps them that way. It verifies nothing — replacing the engine only ever asks `HttpContext.User` who is acting. A request without it is recorded with no actor rather than with a guess. -**No retention is configured and no prune is scheduled**, deliberately: retention does nothing -until a prune runs, and a number in `AddAuditing` would state a policy nothing in the solution -carries out. The trail grows forever, which for a sample seeded on first run is the honest answer. -`o.Retention` plus a hosted service calling `PruneAsync` is the real one. +**Retention is a settings group, and it is off until you turn it on.** The **Audit trail** tab +under Settings (`GET`/`POST /api/settings/auditing`) holds three values: whether old entries are +deleted, how old is old (1–3650 days, 365 by default), and how often to prune (1–168 hours, 24 +by default). `Api/Auditing/AuditRetentionService.cs` reads the group every minute and calls +`PruneOlderThanAsync` when a prune is due, so an edit applies without a restart, and the edit is in +the trail like any other group's save. + +It is only retention because only retention can follow an edit. Every other `AuditingOptions` +value is read once, at `Build()`, and the library says plainly that changing one takes a restart. +A form offering `Enabled` or `RecordDeletedValues` would save values that do nothing until the +process is replaced. For the same reason `o.Retention` stays unset: the prune takes the age as an +argument instead, which also means it ignores a per-entity `[AuditRetention]`. Nothing here +declares one. `POST /api/audit/prune` stays unmapped, so a client can set the policy but cannot +delete history on demand. ## License diff --git a/clients/dashboard/app/components/audit/EntityRules.vue b/clients/dashboard/app/components/audit/EntityRules.vue index e4ba325..665fec3 100644 --- a/clients/dashboard/app/components/audit/EntityRules.vue +++ b/clients/dashboard/app/components/audit/EntityRules.vue @@ -20,6 +20,18 @@ const props = defineProps<{ const { t } = useI18n() +/** + * The retention the API's prune applies, when the `auditing` settings group has it on. + * + * Read only when the model has no retention of its own to report — which, in this application, is + * always: see `useAuditingSettings` for why the library's option stays unset. A failed read falls + * back to the model's answer rather than guessing. + */ +const { data: auditing } = await useAuditingSettings() + +const prunedAfterDays = computed(() => + auditing.value?.pruneEnabled ? auditing.value.retentionDays : null) + /** * The properties by rule, in the order the three are worth reading: recorded, withheld, absent. * @@ -63,9 +75,10 @@ const groups = computed(() => ([ :label="$t('audit.rules.notAudited')" /> - + ([ ? $t('audit.rules.retentionOwn', schema.retentionDays) : $t('audit.rules.retentionDefault', schema.retentionDays)" /> + ( + AUDITING_SETTINGS_KEY, + (_nuxtApp, { signal }) => api.settings.get('auditing', { signal })) +} diff --git a/clients/dashboard/app/types/index.d.ts b/clients/dashboard/app/types/index.d.ts index 4845525..0ceba35 100644 --- a/clients/dashboard/app/types/index.d.ts +++ b/clients/dashboard/app/types/index.d.ts @@ -502,9 +502,10 @@ export interface AuditEntityDescriptor { optedIn: boolean ignored: boolean /** - * Effective retention once a prune runs. `null` means nothing is configured either way, so a - * prune leaves its entries alone — never that they expire immediately. This sample maps no - * prune endpoint, so nothing here ever runs one. + * The library's own retention — `o.Retention` or the entity's `[AuditRetention]`. `null` means + * neither is set, so the library's `PruneAsync` would leave these entries alone — never that they + * expire immediately. This application leaves both unset and prunes by the `auditing` settings + * group instead; see `useAuditingSettings` for the retention that actually applies. */ retentionDays: number | null hasOwnRetention: boolean diff --git a/clients/dashboard/app/utils/settings.ts b/clients/dashboard/app/utils/settings.ts index 4609232..3f4aa67 100644 --- a/clients/dashboard/app/utils/settings.ts +++ b/clients/dashboard/app/utils/settings.ts @@ -21,6 +21,7 @@ * schema, which is what makes discovery real rather than decorative. */ const icons: Record = { + auditing: 'i-lucide-history', branding: 'i-lucide-palette', catalog: 'i-lucide-package' } diff --git a/clients/dashboard/i18n/locales/ar.json b/clients/dashboard/i18n/locales/ar.json index 44dac77..4bb6e7a 100644 --- a/clients/dashboard/i18n/locales/ar.json +++ b/clients/dashboard/i18n/locales/ar.json @@ -328,6 +328,7 @@ "ignore": "لا تُسجَّل أبدًا — القيمة المترجمة تتوقف على اللغة، والمدخل لا لغة له", "retentionOwn": "تُحفظ بلا مدة — بإعدادها الخاص | تُحفظ يومًا واحدًا — بإعدادها الخاص | تُحفظ يومين — بإعدادها الخاص | تُحفظ {n} أيام — بإعدادها الخاص | تُحفظ {n} يومًا — بإعدادها الخاص | تُحفظ {n} يوم — بإعدادها الخاص", "retentionDefault": "تُحفظ بلا مدة — بالإعداد الافتراضي | تُحفظ يومًا واحدًا — بالإعداد الافتراضي | تُحفظ يومين — بالإعداد الافتراضي | تُحفظ {n} أيام — بالإعداد الافتراضي | تُحفظ {n} يومًا — بالإعداد الافتراضي | تُحفظ {n} يوم — بالإعداد الافتراضي", + "retentionPruned": "تُحذف فورًا — بإعداد سجل التدقيق | تُحذف بعد يوم واحد — بإعداد سجل التدقيق | تُحذف بعد يومين — بإعداد سجل التدقيق | تُحذف بعد {n} أيام — بإعداد سجل التدقيق | تُحذف بعد {n} يومًا — بإعداد سجل التدقيق | تُحذف بعد {n} يوم — بإعداد سجل التدقيق", "retentionNone": "تُحفظ بلا حد" } }, @@ -555,6 +556,24 @@ "description": "يظهر إلى جانب الأسعار." } } + }, + "auditing": { + "label": "سجل التدقيق", + "description": "مدة الاحتفاظ بسجل التدقيق. يجري التنظيف داخل الواجهة البرمجية التي تقرأ هذه المجموعة كل دقيقة، فيسري التغيير دون إعادة تشغيل. لا يمكن استرجاع الإدخالات المحذوفة.", + "fields": { + "pruneEnabled": { + "label": "حذف الإدخالات القديمة", + "description": "معطّل افتراضيًا، فيُحفظ السجل بلا حد. عند تفعيله تُحذف في كل تنظيف الإدخالات الأقدم من مدة الاحتفاظ أدناه." + }, + "retentionDays": { + "label": "مدة الاحتفاظ (بالأيام)", + "description": "العمر الذي يُحذف بعده الإدخال عند التنظيف. يسري على جميع الكيانات، بما فيها عمليات حفظ هذه المجموعة نفسها." + }, + "pruneIntervalHours": { + "label": "التنظيف كل (ساعة)", + "description": "عدد مرات التنظيف ما دام الحذف مفعّلًا. تفعيل الحذف ينظّف خلال دقيقة، وهذا الإعداد يحدد الفاصل بعد ذلك." + } + } } }, "form": { diff --git a/clients/dashboard/i18n/locales/en.json b/clients/dashboard/i18n/locales/en.json index f56b485..90881a8 100644 --- a/clients/dashboard/i18n/locales/en.json +++ b/clients/dashboard/i18n/locales/en.json @@ -328,6 +328,7 @@ "ignore": "never recorded — a translated value depends on the language, and an entry has no language", "retentionOwn": "kept {n} day — its own | kept {n} days — its own", "retentionDefault": "kept {n} day — the configured default | kept {n} days — the configured default", + "retentionPruned": "deleted after {n} day — the Audit trail setting | deleted after {n} days — the Audit trail setting", "retentionNone": "kept indefinitely" } }, @@ -555,6 +556,24 @@ "description": "Shown next to prices." } } + }, + "auditing": { + "label": "Audit trail", + "description": "How long the audit trail is kept. Pruning runs in the API, which reads this group every minute, so a change applies without a restart. Deleted entries cannot be recovered.", + "fields": { + "pruneEnabled": { + "label": "Delete old entries", + "description": "Off by default, so the trail is kept indefinitely. When on, entries older than the retention below are deleted at each prune." + }, + "retentionDays": { + "label": "Retention (days)", + "description": "How old an entry must be before a prune deletes it. Applies to every entity, this group's own saves included." + }, + "pruneIntervalHours": { + "label": "Prune every (hours)", + "description": "How often a prune runs while deletion is on. Turning deletion on prunes within a minute; this sets the gap after that." + } + } } }, "form": { diff --git a/clients/dashboard/i18n/locales/fr.json b/clients/dashboard/i18n/locales/fr.json index a502398..70f4e1a 100644 --- a/clients/dashboard/i18n/locales/fr.json +++ b/clients/dashboard/i18n/locales/fr.json @@ -328,6 +328,7 @@ "ignore": "jamais enregistrées — une valeur traduite dépend de la langue, et une entrée n'a pas de langue", "retentionOwn": "conservées {n} jour — sa propre règle | conservées {n} jours — sa propre règle", "retentionDefault": "conservées {n} jour — la valeur par défaut configurée | conservées {n} jours — la valeur par défaut configurée", + "retentionPruned": "supprimées après {n} jour — réglage Journal d'audit | supprimées après {n} jours — réglage Journal d'audit", "retentionNone": "conservées sans limite" } }, @@ -555,6 +556,24 @@ "description": "Affiché à côté des prix." } } + }, + "auditing": { + "label": "Journal d'audit", + "description": "Durée de conservation du journal d'audit. La purge s'exécute dans l'API, qui relit ce groupe chaque minute : un changement s'applique sans redémarrage. Les entrées supprimées ne peuvent pas être récupérées.", + "fields": { + "pruneEnabled": { + "label": "Supprimer les anciennes entrées", + "description": "Désactivé par défaut : le journal est conservé sans limite. Activé, les entrées plus anciennes que la durée ci-dessous sont supprimées à chaque purge." + }, + "retentionDays": { + "label": "Conservation (jours)", + "description": "L'âge à partir duquel une purge supprime une entrée. S'applique à toutes les entités, y compris aux enregistrements de ce groupe." + }, + "pruneIntervalHours": { + "label": "Purger toutes les (heures)", + "description": "La fréquence des purges tant que la suppression est activée. L'activer déclenche une purge dans la minute ; ce réglage fixe l'intervalle ensuite." + } + } } }, "form": { diff --git a/src/DotNetBoostSample.Api/Auditing/AuditRetentionService.cs b/src/DotNetBoostSample.Api/Auditing/AuditRetentionService.cs new file mode 100644 index 0000000..b87c0a9 --- /dev/null +++ b/src/DotNetBoostSample.Api/Auditing/AuditRetentionService.cs @@ -0,0 +1,116 @@ +using DotNetBoost.Auditing.Core.Interfaces; +using DotNetBoost.Settings.Core.Interfaces; +using DotNetBoostSample.Application.Settings; + +namespace DotNetBoostSample.Api.Auditing; + +/// +/// Deletes audit entries past the retention the group states, on the +/// interval it states — the "hosted service calling PruneAsync" that DotNetBoost.Auditing leaves +/// to the application, and that this one used to say it did not have. +/// +/// Why it is in the host. Running something on a timer is a fact about the process, +/// and the host is the only project that owns one — AddInfrastructure is deliberately kept +/// off Microsoft.Extensions.Hosting. It is also the one place both libraries' abstractions +/// are already referenced, through their *.API packages. +/// +/// Not a join. It reads a number from one library and hands it to another, the way +/// ListProducts reads a page size from Settings beside the files it reads from Media. Neither +/// library's subject is said to be the other's, so it is not on the README's list of the files where +/// the composition happens. +/// +/// Why it reads the group every minute rather than sleeping for the interval. The group +/// is runtime-editable, and a service that read it once and slept for twenty-four hours would apply +/// a shortened retention, or a prune switched off, a day late. The read is a cache hit in +/// DotNetBoost.Settings unless the group was just saved, so the minute costs nothing; the interval +/// is measured from the last prune instead. Turning pruning on prunes at the next minute, and +/// turning it off and on again starts the interval over. +/// +/// What it does not use. AuditingOptions.Retention and +/// PruneAsync(): the option is read once at Build(), so it cannot follow the group. +/// PruneOlderThanAsync takes the age as an argument instead — at the cost that it ignores +/// a per-entity [AuditRetention], which nothing in this application declares. If something +/// ever does, this has to prune it separately and exclude it here. +/// +/// One process. Nothing stops two instances of the API from pruning at once. The +/// prune is idempotent and batched, so that costs a wasted sweep rather than a wrong answer — but a +/// deployment with many replicas would want this behind a lease, or in a job of its own. +/// +public sealed partial class AuditRetentionService( + IServiceScopeFactory scopes, + TimeProvider time, + ILogger logger) : BackgroundService +{ + /// How often the group is read to see whether a prune is due. + public static readonly TimeSpan CheckEvery = TimeSpan.FromMinutes(1); + + /// When the last prune ran, or null when none has since pruning was last turned on. + private DateTimeOffset? _lastPruned; + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(CheckEvery, time); + + do + { + try + { + await PruneIfDueAsync(stoppingToken); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + // Logged and carried on: a database that was briefly unreachable must not stop + // retention for the life of the process. The next minute tries again, and a prune + // that was cut short resumes where it stopped — the work is idempotent. + LogPruneFailed(ex); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + /// + /// Reads the group and prunes if it says to and the interval has passed. + /// + /// Public so a test can run one check without waiting for the timer. It is the same method + /// the timer calls, so what the test proves is what runs. + /// + /// How many entries were deleted, or null when no prune was due. + public async Task PruneIfDueAsync(CancellationToken cancellationToken = default) + { + await using var scope = scopes.CreateAsyncScope(); + + var settings = await scope.ServiceProvider + .GetRequiredService() + .For() + .GetAsync(cancellationToken: cancellationToken); + + if (!settings.PruneEnabled) + { + _lastPruned = null; + return null; + } + + var now = time.GetUtcNow(); + + if (_lastPruned is { } last && now - last < TimeSpan.FromHours(settings.PruneIntervalHours)) + return null; + + var deleted = await scope.ServiceProvider + .GetRequiredService() + .PruneOlderThanAsync(TimeSpan.FromDays(settings.RetentionDays), ct: cancellationToken); + + _lastPruned = now; + LogPruned(deleted, settings.RetentionDays); + + return deleted; + } + + [LoggerMessage(Level = LogLevel.Information, + Message = "Pruned {Deleted} audit entries older than {RetentionDays} days.")] + private partial void LogPruned(int deleted, int retentionDays); + + [LoggerMessage(Level = LogLevel.Error, + Message = "Pruning the audit trail failed; the next check will try again.")] + private partial void LogPruneFailed(Exception exception); +} diff --git a/src/DotNetBoostSample.Api/Program.cs b/src/DotNetBoostSample.Api/Program.cs index 71946ce..729f449 100644 --- a/src/DotNetBoostSample.Api/Program.cs +++ b/src/DotNetBoostSample.Api/Program.cs @@ -1,6 +1,7 @@ using DotNetBoost.Auditing.API; using DotNetBoost.Media.API; using DotNetBoost.MultiLingual.API; +using DotNetBoostSample.Api.Auditing; using DotNetBoostSample.Api.Authentication; using DotNetBoostSample.Application; using DotNetBoostSample.Application.Abstractions.Cqrs; @@ -11,6 +12,7 @@ using DotNetBoostSample.Infrastructure; using DotNetBoostSample.Infrastructure.Persistence; using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; using Scalar.AspNetCore; // ───────────────────────────────────────────────────────────────────────────── @@ -22,8 +24,8 @@ // what is installed, and tests/ for the assertion that none of them references another. // // The fourth arrived without moving the other three. Auditing's whole presence in this file is -// one delegate and one Map call; in the rest of the solution it is one Apply* line in -// AppDbContext, one block and one UseAuditing(sp) in AddInfrastructure, one settings observer +// one delegate, one Map call and — added later, for retention — one hosted service; in the rest +// of the solution it is one Apply* line in AppDbContext, one block and one UseAuditing(sp) in AddInfrastructure, one settings observer // beside them, and two attributes in Domain — no handler mentions it. Nothing in the other three moved. That is the strongest // evidence here for what "no shared kernel" is worth, because it is the only one measured by // ADDING a library rather than by reading the ones already in — and the last two items on that @@ -221,6 +223,15 @@ builder.Services.AddAuthorization(); +// ── Retention ─────────────────────────────────────────────────────────────── +// Deletes audit entries past the age the `auditing` settings group states, while that group says +// to — off until someone turns it on. A timer is a fact about the process, so it is registered by +// the host rather than in AddInfrastructure. See Auditing/AuditRetentionService.cs for why it +// reads the group every minute, and why it prunes in-process rather than through POST +// /api/audit/prune, which stays unmapped below. +builder.Services.TryAddSingleton(TimeProvider.System); +builder.Services.AddHostedService(); + var app = builder.Build(); app.MapDefaultEndpoints(); @@ -261,7 +272,9 @@ // • The erasure and prune endpoints are NOT MAPPED — o.MapErasure and o.MapPrune are off by // default, so DELETE /api/audit/entity/… and POST /api/audit/prune do not exist here. They // destroy history. Nothing in a sample needs to, and an endpoint that does should be asked for -// deliberately rather than inherited. +// deliberately rather than inherited. Retention is not a reason to map prune: the policy is +// the `auditing` settings group, and AuditRetentionService carries it out in-process, so no +// HTTP caller can delete history on demand. app.MapAuditEndpoints(); // ── The application's own endpoints ────────────────────────────────────────── diff --git a/src/DotNetBoostSample.Application/Settings/AuditingSettings.cs b/src/DotNetBoostSample.Application/Settings/AuditingSettings.cs new file mode 100644 index 0000000..ae6f63e --- /dev/null +++ b/src/DotNetBoostSample.Application/Settings/AuditingSettings.cs @@ -0,0 +1,65 @@ +using DotNetBoost.Settings.Core.Attributes; +using FluentValidation; + +namespace DotNetBoostSample.Application.Settings; + +/// +/// How long the audit trail is kept, editable from the dashboard: the policy +/// AuditRetentionService, in the host, reads every time it decides whether to prune. +/// +/// Why this is only retention. Everything else DotNetBoost.Auditing can be told is an +/// AuditingOptions value, and that object is read once, at Build() — its own +/// documentation says a change "takes a restart", with no IOptionsMonitor behind it. A +/// group here offering Enabled or RecordDeletedValues would be a form whose saves do +/// nothing until the process is replaced, which is worse than no form. Retention is the exception +/// because the library also takes it as an argument — PruneOlderThanAsync(TimeSpan) — and a +/// prune is something this application runs, so it can read the policy as it runs. +/// +/// Off by default. A prune is the one irreversible thing the library does, and a +/// sample that started deleting its own trail because someone ran it for a year would be the +/// wrong kind of helpful. Turned on, the trail older than goes. +/// +/// Editing this group is itself recorded — SettingGroupAuditObserver writes one entry +/// per save, like any other group's — so who shortened the retention, and from what, is in the +/// trail the change applies to. A prune never removes that entry sooner than any other. +/// +[SettingGroup("auditing")] +public class AuditingSettings +{ + /// Whether entries older than are deleted. + public bool PruneEnabled { get; set; } + + /// How old an entry must be, in days, before a prune deletes it. + public int RetentionDays { get; set; } = 365; + + /// How often, in hours, a prune runs while is on. + public int PruneIntervalHours { get; set; } = 24; +} + +/// +/// Rules for the auditing group. explains why these are +/// published as well as enforced. +/// +public sealed class AuditingSettingsValidator : AbstractValidator +{ + /// The longest retention the form offers: ten years. + public const int MaxRetentionDays = 3650; + + /// The longest gap between prunes: a week. + public const int MaxPruneIntervalHours = 168; + + /// Creates the validator. + public AuditingSettingsValidator() + { + // At least a day, and not only because PruneOlderThanAsync refuses zero — which it does, + // since a zero age would delete the entry being written as it is written. A retention the + // dashboard can set to "now" is a delete-everything button with a number field for a label. + RuleFor(a => a.RetentionDays) + .InclusiveBetween(1, MaxRetentionDays); + + // The service checks this group every minute, so an hour is the finest interval that + // means what it says. + RuleFor(a => a.PruneIntervalHours) + .InclusiveBetween(1, MaxPruneIntervalHours); + } +} diff --git a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs index 2813a4a..bc2753d 100644 --- a/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs +++ b/src/DotNetBoostSample.Infrastructure/DependencyInjection.cs @@ -328,11 +328,12 @@ public static IServiceCollection AddInfrastructure( // would record it twice. .IgnoreEntity(); - // No retention is configured, deliberately. Retention does nothing until a prune runs, - // this sample schedules none, and POST /api/audit/prune is not mapped — so a number here - // would state a policy that nothing in the solution carries out. The trail grows forever, - // which for a sample seeded on first run is the honest answer; `o.Retention` plus a hosted - // service that calls PruneAsync is the real one. + // No `o.Retention` here, deliberately — not because nothing prunes, but because what does + // is runtime-editable. The policy is the `auditing` settings group (AuditingSettings), and + // the host's AuditRetentionService reads it every minute and calls PruneOlderThanAsync + // with the age it states. An option set here is read once, at Build(), so it could not + // follow an edit to that group, and a second number beside it would be a second answer. + // Pruning is off until someone turns it on in that group: until then the trail is kept. // And whose name goes on an entry, which only the host can answer. See the parameter. configureAuditing?.Invoke(auditing); diff --git a/tests/DotNetBoostSample.Tests/Integration/AuditRetentionTests.cs b/tests/DotNetBoostSample.Tests/Integration/AuditRetentionTests.cs new file mode 100644 index 0000000..e23a6f2 --- /dev/null +++ b/tests/DotNetBoostSample.Tests/Integration/AuditRetentionTests.cs @@ -0,0 +1,152 @@ +using DotNetBoost.Auditing.Core.Models; +using DotNetBoostSample.Api.Auditing; +using DotNetBoostSample.Application.Settings; +using DotNetBoostSample.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using Xunit; + +namespace DotNetBoostSample.Tests.Integration; + +/// +/// Retention, as the auditing settings group states it and +/// carries it out: nothing is deleted while the group says off, and once it says on, what is older +/// than its retention goes and nothing younger does. +/// +/// How "older" is made. An entry's time comes from the engine's clock, and a test cannot +/// wait three days for one to age. So the entries a test wants old are moved back with a bulk +/// update straight against the table — which the interceptor never sees, so the move is not itself +/// recorded — and everything else about them is what the API wrote. +/// +/// Why the prune is called rather than waited for. PruneIfDueAsync is the method +/// the service's timer calls, on the instance the host is running, so calling it is running the +/// service one minute early. The timer may also fire during a test; a prune is idempotent, so the +/// assertions are about what the table holds afterwards rather than about how many a call +/// deleted. +/// +/// Every test that turns pruning on turns it off again in a finally: the collection +/// shares one database, and a retention left on would be deleting under the tests that follow. +/// +public abstract class AuditRetentionTests(SampleApp app) +{ + /// + /// The group starts switched off, and publishes the bounds its validator enforces — at least a + /// day, because a retention of zero would be a delete-everything button. + /// + [RequiresDockerFact] + public async Task The_group_starts_switched_off_and_publishes_its_bounds() + { + var values = await app.Client.GetJsonAsync("/api/settings/auditing"); + + Assert.False(values.GetProperty("pruneEnabled").GetBoolean()); + Assert.Equal(365, values.Int("retentionDays")); + Assert.Equal(24, values.Int("pruneIntervalHours")); + + var schema = await app.Client.GetJsonAsync("/api/settings/auditing/schema"); + + var retention = Property(schema, "retentionDays").GetProperty("constraints"); + Assert.Equal(1, retention.Int("min")); + Assert.Equal(AuditingSettingsValidator.MaxRetentionDays, retention.Int("max")); + + var interval = Property(schema, "pruneIntervalHours").GetProperty("constraints"); + Assert.Equal(1, interval.Int("min")); + Assert.Equal(AuditingSettingsValidator.MaxPruneIntervalHours, interval.Int("max")); + } + + /// + /// Off means off: an entry three days old survives a check against a group that has not been + /// turned on, whatever its retention says. + /// + [RequiresDockerFact] + public async Task Nothing_is_pruned_while_the_group_says_off() + { + var client = app.Client.AsActor(); + var id = await client.CreateProductAsync(app.SeededCategoryId, "Kept while off"); + + await AgeAsync("Product", id, TimeSpan.FromDays(3)); + + Assert.Null(await app.HostedService().PruneIfDueAsync()); + + Assert.Single(await Trail(client, "Product", id)); + } + + /// + /// Turned on with a retention of one day: the trail of a product whose entries are three days + /// old is gone, a product created now keeps its own, and the save that turned pruning on is in + /// the trail with who made it. + /// + [RequiresDockerFact] + public async Task Turned_on_it_deletes_what_is_past_retention_and_keeps_the_rest() + { + var client = app.Client.AsActor(); + var old = await client.CreateProductAsync(app.SeededCategoryId, "Past retention"); + var recent = await client.CreateProductAsync(app.SeededCategoryId, "Within retention"); + + await AgeAsync("Product", old, TimeSpan.FromDays(3)); + + try + { + await client.PostAsJsonChecked("/api/settings/auditing", new + { + pruneEnabled = true, + retentionDays = 1, + pruneIntervalHours = 24 + }); + + await app.HostedService().PruneIfDueAsync(); + + Assert.Empty(await Trail(client, "Product", old)); + Assert.Single(await Trail(client, "Product", recent)); + + // Who changed the retention is recorded like any other group's save — and, being new, it + // is not among what that retention deletes. + var turnedOn = (await client.GetJsonAsync( + $"/api/audit?entityName={SettingGroupAuditObserver.EntityName}&entityId={nameof(AuditingSettings)}")) + .GetProperty("entries").EnumerateArray().First(); + + Assert.Equal(Http.DefaultActor, turnedOn.Str("actor")); + Assert.Contains( + turnedOn.GetProperty("changes").EnumerateArray(), + change => change.Str("propertyName") == nameof(AuditingSettings.PruneEnabled)); + } + finally + { + await app.ResetAuditingSettingsAsync(); + } + } + + /// + /// Moves every entry about one entity back by , with a bulk update the + /// change tracker — and so the audit interceptor — never sees. + /// + private Task AgeAsync(string entityName, int entityId, TimeSpan age) => + app.WithDbAsync(db => db.Set() + .Where(e => e.EntityName == entityName && e.EntityId == entityId.ToString()) + .ExecuteUpdateAsync(set => set.SetProperty(e => e.OccurredAt, e => e.OccurredAt - age))); + + /// One entity's entries, newest first. + private static async Task> Trail(HttpClient client, string entityName, int entityId) => + [.. (await client.GetJsonAsync($"/api/audit?entityName={entityName}&entityId={entityId}")) + .GetProperty("entries").EnumerateArray()]; + + private static JsonElement Property(JsonElement schema, string name) => + schema.GetProperty("properties").EnumerateArray().Single(p => p.Str("name") == name); +} + +/// +/// +/// Run in the mode the sample ships in: every language is a translation row. +/// +[Collection(SampleAppCollection.Name)] +public sealed class AuditRetentionTestsInDefaultMode(SampleApp app) : AuditRetentionTests(app); + +/// +/// The same assertions about retention, against the same application booted with +/// MultiLingualOptions.BaseLanguage = "en". +/// +/// Running them twice rather than reasoning about which ones could care is the point: the +/// mode moves content between a column and a translation row, and the claim this sample makes is +/// that no caller can tell. A claim like that is worth more re-run than re-read. +/// +[Collection(BaseLanguageSampleAppCollection.Name)] +public sealed class AuditRetentionTestsInBaseLanguageMode(BaseLanguageSampleApp app) : AuditRetentionTests(app); diff --git a/tests/DotNetBoostSample.Tests/Integration/OpenApiShapeTests.cs b/tests/DotNetBoostSample.Tests/Integration/OpenApiShapeTests.cs index 28d315e..1d942de 100644 --- a/tests/DotNetBoostSample.Tests/Integration/OpenApiShapeTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/OpenApiShapeTests.cs @@ -105,7 +105,7 @@ public async Task The_documented_branding_request_is_the_stored_group_alone() /// where they are now called. Nothing else in the repository had to change for them to appear. /// /// - /// Why the list rather than the count. 45 on its own survives one route being + /// Why the list rather than the count. 48 on its own survives one route being /// swapped for another, which is precisely the change that leaves a route uncalled while the /// number still reads true. Listed, the failure names the route — call it in a test and add it /// here, or delete the line and correct the README. @@ -132,7 +132,7 @@ from operation in path.Value.EnumerateObject() } /// - /// The 45, in the order puts them — which groups them by + /// The 48, in the order puts them — which groups them by /// method, so a new route lands where it can be read against its neighbours. /// private static readonly string[] Published = @@ -165,6 +165,8 @@ from operation in path.Value.EnumerateObject() "GET /api/products", "GET /api/products/{id}", "GET /api/settings", + "GET /api/settings/auditing", + "GET /api/settings/auditing/schema", "GET /api/settings/branding", "GET /api/settings/branding/schema", "GET /api/settings/catalog", @@ -181,6 +183,7 @@ from operation in path.Value.EnumerateObject() "POST /api/media/{id}/attachments", "POST /api/products", "POST /api/products/{id}/images", + "POST /api/settings/auditing", "POST /api/settings/branding", "POST /api/settings/catalog", "POST /api/translations/{entityName}/resolve", diff --git a/tests/DotNetBoostSample.Tests/Integration/RemainingRouteTests.cs b/tests/DotNetBoostSample.Tests/Integration/RemainingRouteTests.cs index d34a178..711f2a0 100644 --- a/tests/DotNetBoostSample.Tests/Integration/RemainingRouteTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/RemainingRouteTests.cs @@ -13,9 +13,9 @@ namespace DotNetBoostSample.Tests.Integration; /// they are the difference between "the suite is green" and "every published route has been /// called". /// -/// And the forty-five is pinned rather than remembered. A cross-check done by hand -/// holds only until the next route is mapped — by this application or by the next version of a -/// library whose endpoints it publishes. OpenApiShapeTests asserts the inventory by name, so +/// And the inventory is pinned rather than remembered — forty-eight routes now. A +/// cross-check done by hand holds only until the next route is mapped — by this application or by +/// the next version of a library whose endpoints it publishes. OpenApiShapeTests asserts the inventory by name, so /// a new route arrives as a failing test with that route in the message, which is the prompt to /// call it here. /// diff --git a/tests/DotNetBoostSample.Tests/Integration/SampleApp.cs b/tests/DotNetBoostSample.Tests/Integration/SampleApp.cs index b0e3687..37017f3 100644 --- a/tests/DotNetBoostSample.Tests/Integration/SampleApp.cs +++ b/tests/DotNetBoostSample.Tests/Integration/SampleApp.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Testcontainers.PostgreSql; using Xunit; @@ -195,6 +196,26 @@ public WebApplicationFactory Boot(string connectionString) => builder.UseSetting("MultiLingual:BaseLanguage", BaseLanguage); }); + /// + /// The running instance of a hosted service, for a test that drives one step of it rather than + /// waiting for its timer — AuditRetentionService.PruneIfDueAsync, which is what the + /// timer calls. + /// + public T HostedService() where T : IHostedService => + _factory!.Services.GetServices().OfType().Single(); + + /// + /// Puts the auditing settings group back to its defaults — pruning off — so a test that turned + /// it on cannot leave the shared database pruning under the tests after it. + /// + public Task ResetAuditingSettingsAsync() => + Client.PostAsJsonChecked("/api/settings/auditing", new + { + pruneEnabled = false, + retentionDays = 365, + pruneIntervalHours = 24 + }); + /// Resets the catalogue settings group, so one test's edit cannot reach another. public Task ResetCatalogSettingsAsync() => Client.PostAsJsonChecked("/api/settings/catalog", new diff --git a/tests/DotNetBoostSample.Tests/Integration/SettingsSchemaTests.cs b/tests/DotNetBoostSample.Tests/Integration/SettingsSchemaTests.cs index 14549b2..2948876 100644 --- a/tests/DotNetBoostSample.Tests/Integration/SettingsSchemaTests.cs +++ b/tests/DotNetBoostSample.Tests/Integration/SettingsSchemaTests.cs @@ -24,7 +24,7 @@ public async Task The_group_list_names_every_registered_group_and_no_values() var routes = groups.EnumerateArray().Select(g => g.Str("route")).Order().ToList(); - Assert.Equal(["branding", "catalog"], routes); + Assert.Equal(["auditing", "branding", "catalog"], routes); // Routes and names, never values: this endpoint is meant to be safe to expose more widely // than the groups themselves. From 445d70a4ef49c31f53e920def7a57b5c661b169e Mon Sep 17 00:00:00 2001 From: Sadeq Abu-Hattem Date: Tue, 27 Oct 2026 01:41:08 +0300 Subject: [PATCH 15/44] Rebuild the dashboard on shadcn-vue, after its dashboard-01 and sidebar blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuxt UI is gone. The primitives are shadcn-vue's reka-vega style, added with its CLI into app/components/ui, with Tailwind, @nuxt/fonts and @nuxtjs/color-mode installed directly now that Nuxt UI no longer brings them. The blocks are only published in the new-york-v4 style, so the shell is adapted from dashboard-01 and sidebar-07 by hand: an inset sidebar that collapses to icons, a sticky page header, a ⌘K command palette, and the appearance menu where the blocks keep the user. The overview takes the block's section cards and area chart, drawn from the audit trail's entries per day. Every page and editor is moved over, with shared pieces for what Nuxt UI had and shadcn-vue does not: DashboardPage, EditorSheet, PagerControls, LanguagePills and SuggestInput, plus a drop zone for uploads. shadcn-vue/tailwind.css is imported by hand, because init leaves it out and without it no switch, menu or dialog shows its state. The mobile sidebar sheet, sidebar tooltips and switch thumb are patched to mirror in Arabic. .mcp.json adds shadcn-vue's MCP server, and clients/dashboard/CLAUDE.md the rules for UI work. The lockfile is regenerated in node:22-alpine, as CI installs it. Co-Authored-By: Claude Opus 5.5 --- .mcp.json | 8 + README.md | 6 +- clients/dashboard/CLAUDE.md | 39 + clients/dashboard/app/app.config.ts | 8 - clients/dashboard/app/app.vue | 35 +- clients/dashboard/app/assets/css/main.css | 248 +- clients/dashboard/app/components/AppBrand.vue | 43 +- .../dashboard/app/components/AppSidebar.vue | 101 + .../app/components/AppearanceMenu.vue | 228 +- .../app/components/CategoryEditor.vue | 125 +- .../app/components/CommandPalette.vue | 72 + .../dashboard/app/components/ConfirmModal.vue | 40 +- .../app/components/DashboardPage.vue | 49 + .../dashboard/app/components/EditorSheet.vue | 47 + .../dashboard/app/components/GithubIcon.vue | 20 + .../app/components/LanguagePills.vue | 42 + .../app/components/LanguageSelect.vue | 43 +- .../app/components/PagerControls.vue | 56 + .../app/components/ProductEditor.vue | 171 +- .../dashboard/app/components/SectionIntro.vue | 26 + .../dashboard/app/components/SuggestInput.vue | 127 + .../app/components/TranslationEditor.vue | 50 +- .../app/components/audit/EntityRules.vue | 95 +- .../app/components/audit/EntryChanges.vue | 70 +- .../app/components/audit/Filters.vue | 244 +- .../app/components/entity/Attachments.vue | 13 +- .../app/components/entity/RoleBox.vue | 70 +- .../app/components/languages/FormModal.vue | 277 +- .../app/components/media/AltTextSection.vue | 93 +- .../app/components/media/Browser.vue | 112 +- .../dashboard/app/components/media/Card.vue | 87 +- .../app/components/media/DetailSlideover.vue | 342 +- .../app/components/media/Filters.vue | 116 +- .../app/components/media/PickerModal.vue | 123 +- .../app/components/media/Thumbnail.vue | 8 +- .../app/components/media/UploadModal.vue | 223 +- .../app/components/overview/ActivityChart.vue | 197 + .../app/components/settings/GroupForm.vue | 318 +- .../app/components/settings/GroupText.vue | 116 +- .../ui/alert-dialog/AlertDialog.vue | 15 + .../ui/alert-dialog/AlertDialogAction.vue | 33 + .../ui/alert-dialog/AlertDialogCancel.vue | 37 + .../ui/alert-dialog/AlertDialogContent.vue | 53 + .../alert-dialog/AlertDialogDescription.vue | 23 + .../ui/alert-dialog/AlertDialogFooter.vue | 22 + .../ui/alert-dialog/AlertDialogHeader.vue | 17 + .../ui/alert-dialog/AlertDialogMedia.vue | 17 + .../ui/alert-dialog/AlertDialogTitle.vue | 21 + .../ui/alert-dialog/AlertDialogTrigger.vue | 12 + .../app/components/ui/alert-dialog/index.ts | 10 + .../app/components/ui/alert/Alert.vue | 21 + .../app/components/ui/alert/AlertAction.vue | 17 + .../components/ui/alert/AlertDescription.vue | 17 + .../app/components/ui/alert/AlertTitle.vue | 17 + .../app/components/ui/alert/index.ts | 21 + .../app/components/ui/avatar/Avatar.vue | 22 + .../app/components/ui/avatar/AvatarBadge.vue | 23 + .../components/ui/avatar/AvatarFallback.vue | 21 + .../app/components/ui/avatar/AvatarGroup.vue | 20 + .../components/ui/avatar/AvatarGroupCount.vue | 20 + .../app/components/ui/avatar/AvatarImage.vue | 16 + .../app/components/ui/avatar/index.ts | 27 + .../app/components/ui/badge/Badge.vue | 27 + .../app/components/ui/badge/index.ts | 24 + .../components/ui/breadcrumb/Breadcrumb.vue | 18 + .../ui/breadcrumb/BreadcrumbEllipsis.vue | 24 + .../ui/breadcrumb/BreadcrumbItem.vue | 17 + .../ui/breadcrumb/BreadcrumbLink.vue | 21 + .../ui/breadcrumb/BreadcrumbList.vue | 17 + .../ui/breadcrumb/BreadcrumbPage.vue | 20 + .../ui/breadcrumb/BreadcrumbSeparator.vue | 23 + .../app/components/ui/breadcrumb/index.ts | 7 + .../ui/button-group/ButtonGroup.vue | 22 + .../ui/button-group/ButtonGroupSeparator.vue | 24 + .../ui/button-group/ButtonGroupText.vue | 28 + .../app/components/ui/button-group/index.ts | 25 + .../app/components/ui/button/Button.vue | 31 + .../app/components/ui/button/index.ts | 35 + .../app/components/ui/calendar/Calendar.vue | 176 + .../components/ui/calendar/CalendarCell.vue | 23 + .../ui/calendar/CalendarCellTrigger.vue | 39 + .../components/ui/calendar/CalendarGrid.vue | 23 + .../ui/calendar/CalendarGridBody.vue | 15 + .../ui/calendar/CalendarGridHead.vue | 16 + .../ui/calendar/CalendarGridRow.vue | 23 + .../ui/calendar/CalendarHeadCell.vue | 23 + .../components/ui/calendar/CalendarHeader.vue | 23 + .../ui/calendar/CalendarHeading.vue | 30 + .../ui/calendar/CalendarNextButton.vue | 32 + .../ui/calendar/CalendarPrevButton.vue | 32 + .../app/components/ui/calendar/index.ts | 14 + .../dashboard/app/components/ui/card/Card.vue | 21 + .../app/components/ui/card/CardAction.vue | 17 + .../app/components/ui/card/CardContent.vue | 17 + .../components/ui/card/CardDescription.vue | 17 + .../app/components/ui/card/CardFooter.vue | 17 + .../app/components/ui/card/CardHeader.vue | 17 + .../app/components/ui/card/CardTitle.vue | 17 + .../dashboard/app/components/ui/card/index.ts | 7 + .../components/ui/chart/ChartContainer.vue | 59 + .../ui/chart/ChartLegendContent.vue | 60 + .../app/components/ui/chart/ChartStyle.vue | 42 + .../ui/chart/ChartTooltipContent.vue | 105 + .../app/components/ui/chart/index.ts | 29 + .../app/components/ui/chart/utils.ts | 44 + .../app/components/ui/checkbox/Checkbox.vue | 34 + .../app/components/ui/checkbox/index.ts | 1 + .../components/ui/collapsible/Collapsible.vue | 19 + .../ui/collapsible/CollapsibleContent.vue | 15 + .../ui/collapsible/CollapsibleTrigger.vue | 15 + .../app/components/ui/collapsible/index.ts | 3 + .../app/components/ui/combobox/Combobox.vue | 19 + .../components/ui/combobox/ComboboxAnchor.vue | 23 + .../components/ui/combobox/ComboboxEmpty.vue | 21 + .../components/ui/combobox/ComboboxGroup.vue | 27 + .../components/ui/combobox/ComboboxInput.vue | 37 + .../components/ui/combobox/ComboboxItem.vue | 24 + .../ui/combobox/ComboboxItemIndicator.vue | 23 + .../components/ui/combobox/ComboboxList.vue | 33 + .../ui/combobox/ComboboxSeparator.vue | 21 + .../ui/combobox/ComboboxTrigger.vue | 24 + .../ui/combobox/ComboboxViewport.vue | 23 + .../app/components/ui/combobox/index.ts | 13 + .../app/components/ui/command/Command.vue | 88 + .../components/ui/command/CommandDialog.vue | 39 + .../components/ui/command/CommandEmpty.vue | 28 + .../components/ui/command/CommandGroup.vue | 45 + .../components/ui/command/CommandInput.vue | 45 + .../app/components/ui/command/CommandItem.vue | 77 + .../app/components/ui/command/CommandList.vue | 25 + .../ui/command/CommandSeparator.vue | 21 + .../components/ui/command/CommandShortcut.vue | 17 + .../app/components/ui/command/index.ts | 25 + .../app/components/ui/dialog/Dialog.vue | 19 + .../app/components/ui/dialog/DialogClose.vue | 15 + .../components/ui/dialog/DialogContent.vue | 53 + .../ui/dialog/DialogDescription.vue | 23 + .../app/components/ui/dialog/DialogFooter.vue | 27 + .../app/components/ui/dialog/DialogHeader.vue | 17 + .../components/ui/dialog/DialogOverlay.vue | 21 + .../ui/dialog/DialogScrollContent.vue | 60 + .../app/components/ui/dialog/DialogTitle.vue | 23 + .../components/ui/dialog/DialogTrigger.vue | 15 + .../app/components/ui/dialog/index.ts | 10 + .../ui/dropdown-menu/DropdownMenu.vue | 19 + .../DropdownMenuCheckboxItem.vue | 43 + .../ui/dropdown-menu/DropdownMenuContent.vue | 40 + .../ui/dropdown-menu/DropdownMenuGroup.vue | 15 + .../ui/dropdown-menu/DropdownMenuItem.vue | 31 + .../ui/dropdown-menu/DropdownMenuLabel.vue | 23 + .../dropdown-menu/DropdownMenuRadioGroup.vue | 21 + .../dropdown-menu/DropdownMenuRadioItem.vue | 44 + .../dropdown-menu/DropdownMenuSeparator.vue | 23 + .../ui/dropdown-menu/DropdownMenuShortcut.vue | 17 + .../ui/dropdown-menu/DropdownMenuSub.vue | 18 + .../dropdown-menu/DropdownMenuSubContent.vue | 27 + .../dropdown-menu/DropdownMenuSubTrigger.vue | 32 + .../ui/dropdown-menu/DropdownMenuTrigger.vue | 17 + .../app/components/ui/dropdown-menu/index.ts | 16 + .../app/components/ui/empty/Empty.vue | 20 + .../app/components/ui/empty/EmptyContent.vue | 20 + .../components/ui/empty/EmptyDescription.vue | 20 + .../app/components/ui/empty/EmptyHeader.vue | 20 + .../app/components/ui/empty/EmptyMedia.vue | 23 + .../app/components/ui/empty/EmptyTitle.vue | 17 + .../app/components/ui/empty/index.ts | 26 + .../app/components/ui/field/Field.vue | 27 + .../app/components/ui/field/FieldContent.vue | 20 + .../components/ui/field/FieldDescription.vue | 22 + .../app/components/ui/field/FieldError.vue | 53 + .../app/components/ui/field/FieldGroup.vue | 20 + .../app/components/ui/field/FieldLabel.vue | 22 + .../app/components/ui/field/FieldLegend.vue | 21 + .../components/ui/field/FieldSeparator.vue | 29 + .../app/components/ui/field/FieldSet.vue | 17 + .../app/components/ui/field/FieldTitle.vue | 20 + .../app/components/ui/field/index.ts | 31 + .../components/ui/input-group/InputGroup.vue | 21 + .../ui/input-group/InputGroupAddon.vue | 36 + .../ui/input-group/InputGroupButton.vue | 22 + .../ui/input-group/InputGroupInput.vue | 19 + .../ui/input-group/InputGroupText.vue | 19 + .../ui/input-group/InputGroupTextarea.vue | 19 + .../app/components/ui/input-group/index.ts | 55 + .../app/components/ui/input/Input.vue | 31 + .../app/components/ui/input/index.ts | 1 + .../dashboard/app/components/ui/item/Item.vue | 31 + .../app/components/ui/item/ItemActions.vue | 17 + .../app/components/ui/item/ItemContent.vue | 17 + .../components/ui/item/ItemDescription.vue | 20 + .../app/components/ui/item/ItemFooter.vue | 17 + .../app/components/ui/item/ItemGroup.vue | 18 + .../app/components/ui/item/ItemHeader.vue | 17 + .../app/components/ui/item/ItemMedia.vue | 21 + .../app/components/ui/item/ItemSeparator.vue | 18 + .../app/components/ui/item/ItemTitle.vue | 17 + .../dashboard/app/components/ui/item/index.ts | 54 + .../dashboard/app/components/ui/kbd/Kbd.vue | 17 + .../app/components/ui/kbd/KbdGroup.vue | 17 + .../dashboard/app/components/ui/kbd/index.ts | 2 + .../app/components/ui/label/Label.vue | 26 + .../app/components/ui/label/index.ts | 1 + .../ui/native-select/NativeSelect.vue | 48 + .../ui/native-select/NativeSelectOptGroup.vue | 12 + .../ui/native-select/NativeSelectOption.vue | 12 + .../app/components/ui/native-select/index.ts | 3 + .../ui/number-field/NumberField.vue | 20 + .../ui/number-field/NumberFieldContent.vue | 14 + .../ui/number-field/NumberFieldDecrement.vue | 23 + .../ui/number-field/NumberFieldIncrement.vue | 23 + .../ui/number-field/NumberFieldInput.vue | 19 + .../app/components/ui/number-field/index.ts | 5 + .../components/ui/pagination/Pagination.vue | 26 + .../ui/pagination/PaginationContent.vue | 22 + .../ui/pagination/PaginationEllipsis.vue | 26 + .../ui/pagination/PaginationFirst.vue | 34 + .../ui/pagination/PaginationItem.vue | 34 + .../ui/pagination/PaginationLast.vue | 34 + .../ui/pagination/PaginationLink.vue | 35 + .../ui/pagination/PaginationNext.vue | 34 + .../ui/pagination/PaginationPrevious.vue | 34 + .../app/components/ui/pagination/index.ts | 9 + .../app/components/ui/popover/Popover.vue | 19 + .../components/ui/popover/PopoverAnchor.vue | 15 + .../components/ui/popover/PopoverContent.vue | 45 + .../ui/popover/PopoverDescription.vue | 17 + .../components/ui/popover/PopoverHeader.vue | 17 + .../components/ui/popover/PopoverTitle.vue | 17 + .../components/ui/popover/PopoverTrigger.vue | 15 + .../app/components/ui/popover/index.ts | 7 + .../app/components/ui/progress/Progress.vue | 38 + .../app/components/ui/progress/index.ts | 1 + .../ui/range-calendar/RangeCalendar.vue | 63 + .../ui/range-calendar/RangeCalendarCell.vue | 23 + .../RangeCalendarCellTrigger.vue | 41 + .../ui/range-calendar/RangeCalendarGrid.vue | 23 + .../range-calendar/RangeCalendarGridBody.vue | 15 + .../range-calendar/RangeCalendarGridHead.vue | 15 + .../range-calendar/RangeCalendarGridRow.vue | 23 + .../range-calendar/RangeCalendarHeadCell.vue | 23 + .../ui/range-calendar/RangeCalendarHeader.vue | 23 + .../range-calendar/RangeCalendarHeading.vue | 30 + .../RangeCalendarNextButton.vue | 33 + .../RangeCalendarPrevButton.vue | 33 + .../app/components/ui/range-calendar/index.ts | 12 + .../components/ui/scroll-area/ScrollArea.vue | 33 + .../components/ui/scroll-area/ScrollBar.vue | 27 + .../app/components/ui/scroll-area/index.ts | 2 + .../app/components/ui/select/Select.vue | 19 + .../components/ui/select/SelectContent.vue | 58 + .../app/components/ui/select/SelectGroup.vue | 21 + .../app/components/ui/select/SelectItem.vue | 45 + .../components/ui/select/SelectItemText.vue | 15 + .../app/components/ui/select/SelectLabel.vue | 17 + .../ui/select/SelectScrollDownButton.vue | 27 + .../ui/select/SelectScrollUpButton.vue | 27 + .../components/ui/select/SelectSeparator.vue | 19 + .../components/ui/select/SelectTrigger.vue | 34 + .../app/components/ui/select/SelectValue.vue | 15 + .../app/components/ui/select/index.ts | 11 + .../app/components/ui/separator/Separator.vue | 29 + .../app/components/ui/separator/index.ts | 1 + .../app/components/ui/sheet/Sheet.vue | 19 + .../app/components/ui/sheet/SheetClose.vue | 15 + .../app/components/ui/sheet/SheetContent.vue | 61 + .../components/ui/sheet/SheetDescription.vue | 21 + .../app/components/ui/sheet/SheetFooter.vue | 15 + .../app/components/ui/sheet/SheetHeader.vue | 15 + .../app/components/ui/sheet/SheetOverlay.vue | 21 + .../app/components/ui/sheet/SheetTitle.vue | 21 + .../app/components/ui/sheet/SheetTrigger.vue | 15 + .../app/components/ui/sheet/index.ts | 8 + .../app/components/ui/sidebar/Sidebar.vue | 116 + .../components/ui/sidebar/SidebarContent.vue | 18 + .../components/ui/sidebar/SidebarFooter.vue | 18 + .../components/ui/sidebar/SidebarGroup.vue | 18 + .../ui/sidebar/SidebarGroupAction.vue | 25 + .../ui/sidebar/SidebarGroupContent.vue | 18 + .../ui/sidebar/SidebarGroupLabel.vue | 24 + .../components/ui/sidebar/SidebarHeader.vue | 18 + .../components/ui/sidebar/SidebarInput.vue | 19 + .../components/ui/sidebar/SidebarInset.vue | 20 + .../app/components/ui/sidebar/SidebarMenu.vue | 18 + .../ui/sidebar/SidebarMenuAction.vue | 30 + .../ui/sidebar/SidebarMenuBadge.vue | 21 + .../ui/sidebar/SidebarMenuButton.vue | 52 + .../ui/sidebar/SidebarMenuButtonChild.vue | 36 + .../components/ui/sidebar/SidebarMenuItem.vue | 18 + .../ui/sidebar/SidebarMenuSkeleton.vue | 35 + .../components/ui/sidebar/SidebarMenuSub.vue | 21 + .../ui/sidebar/SidebarMenuSubButton.vue | 32 + .../ui/sidebar/SidebarMenuSubItem.vue | 18 + .../components/ui/sidebar/SidebarProvider.vue | 82 + .../app/components/ui/sidebar/SidebarRail.vue | 33 + .../ui/sidebar/SidebarSeparator.vue | 19 + .../components/ui/sidebar/SidebarTrigger.vue | 28 + .../app/components/ui/sidebar/index.ts | 59 + .../app/components/ui/sidebar/utils.ts | 19 + .../app/components/ui/skeleton/Skeleton.vue | 17 + .../app/components/ui/skeleton/index.ts | 1 + .../app/components/ui/sonner/Sonner.vue | 62 + .../app/components/ui/sonner/index.ts | 1 + .../app/components/ui/spinner/Spinner.vue | 14 + .../app/components/ui/spinner/index.ts | 1 + .../app/components/ui/switch/Switch.vue | 44 + .../app/components/ui/switch/index.ts | 1 + .../app/components/ui/table/Table.vue | 16 + .../app/components/ui/table/TableBody.vue | 17 + .../app/components/ui/table/TableCaption.vue | 17 + .../app/components/ui/table/TableCell.vue | 17 + .../app/components/ui/table/TableEmpty.vue | 34 + .../app/components/ui/table/TableFooter.vue | 17 + .../app/components/ui/table/TableHead.vue | 17 + .../app/components/ui/table/TableHeader.vue | 17 + .../app/components/ui/table/TableRow.vue | 17 + .../app/components/ui/table/index.ts | 9 + .../dashboard/app/components/ui/tabs/Tabs.vue | 25 + .../app/components/ui/tabs/TabsContent.vue | 21 + .../app/components/ui/tabs/TabsList.vue | 29 + .../app/components/ui/tabs/TabsTrigger.vue | 29 + .../dashboard/app/components/ui/tabs/index.ts | 24 + .../components/ui/tags-input/TagsInput.vue | 28 + .../ui/tags-input/TagsInputInput.vue | 17 + .../ui/tags-input/TagsInputItem.vue | 20 + .../ui/tags-input/TagsInputItemDelete.vue | 23 + .../ui/tags-input/TagsInputItemText.vue | 17 + .../app/components/ui/tags-input/index.ts | 5 + .../app/components/ui/textarea/Textarea.vue | 28 + .../app/components/ui/textarea/index.ts | 1 + .../ui/toggle-group/ToggleGroup.vue | 49 + .../ui/toggle-group/ToggleGroupItem.vue | 45 + .../app/components/ui/toggle-group/index.ts | 2 + .../app/components/ui/toggle/Toggle.vue | 35 + .../app/components/ui/toggle/index.ts | 27 + .../app/components/ui/tooltip/Tooltip.vue | 19 + .../components/ui/tooltip/TooltipContent.vue | 34 + .../components/ui/tooltip/TooltipProvider.vue | 14 + .../components/ui/tooltip/TooltipTrigger.vue | 15 + .../app/components/ui/tooltip/index.ts | 4 + .../dashboard/app/composables/useAccent.ts | 17 + .../app/composables/useCommandPalette.ts | 8 + .../dashboard/app/composables/useLanguages.ts | 4 +- .../app/composables/useMediaLibrary.ts | 4 +- .../app/composables/useNavigation.ts | 49 + .../dashboard/app/composables/useNotify.ts | 10 +- .../app/composables/useSettingsGroup.ts | 9 +- .../app/composables/useShellLocale.ts | 21 +- clients/dashboard/app/error.vue | 24 +- clients/dashboard/app/layouts/default.vue | 112 +- clients/dashboard/app/lib/utils.ts | 1 + clients/dashboard/app/pages/audit.vue | 387 +- clients/dashboard/app/pages/catalog.vue | 513 +- clients/dashboard/app/pages/index.vue | 190 +- clients/dashboard/app/pages/languages.vue | 348 +- clients/dashboard/app/pages/media.vue | 26 +- clients/dashboard/app/pages/settings.vue | 76 +- .../dashboard/app/pages/settings/[route].vue | 2 +- .../dashboard/app/pages/settings/index.vue | 2 +- .../dashboard/app/spa-loading-template.html | 8 +- clients/dashboard/app/types/index.d.ts | 4 +- clients/dashboard/app/utils/format.ts | 27 +- clients/dashboard/app/utils/settings.ts | 15 +- clients/dashboard/components.json | 24 + clients/dashboard/eslint.config.mjs | 10 + clients/dashboard/i18n/locales/ar.json | 55 +- clients/dashboard/i18n/locales/en.json | 55 +- clients/dashboard/i18n/locales/fr.json | 55 +- clients/dashboard/nuxt.config.ts | 28 +- clients/dashboard/package-lock.json | 10688 +++++++++------- clients/dashboard/package.json | 19 +- .../test/nuxt/useLanguageList.spec.ts | 4 +- .../test/nuxt/useSettingsGroup.spec.ts | 11 +- clients/dashboard/vitest.config.ts | 4 +- 373 files changed, 17558 insertions(+), 6829 deletions(-) create mode 100644 .mcp.json create mode 100644 clients/dashboard/CLAUDE.md delete mode 100644 clients/dashboard/app/app.config.ts create mode 100644 clients/dashboard/app/components/AppSidebar.vue create mode 100644 clients/dashboard/app/components/CommandPalette.vue create mode 100644 clients/dashboard/app/components/DashboardPage.vue create mode 100644 clients/dashboard/app/components/EditorSheet.vue create mode 100644 clients/dashboard/app/components/GithubIcon.vue create mode 100644 clients/dashboard/app/components/LanguagePills.vue create mode 100644 clients/dashboard/app/components/PagerControls.vue create mode 100644 clients/dashboard/app/components/SectionIntro.vue create mode 100644 clients/dashboard/app/components/SuggestInput.vue create mode 100644 clients/dashboard/app/components/overview/ActivityChart.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialog.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogAction.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogCancel.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogContent.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogDescription.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogFooter.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogHeader.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogMedia.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogTitle.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/AlertDialogTrigger.vue create mode 100644 clients/dashboard/app/components/ui/alert-dialog/index.ts create mode 100644 clients/dashboard/app/components/ui/alert/Alert.vue create mode 100644 clients/dashboard/app/components/ui/alert/AlertAction.vue create mode 100644 clients/dashboard/app/components/ui/alert/AlertDescription.vue create mode 100644 clients/dashboard/app/components/ui/alert/AlertTitle.vue create mode 100644 clients/dashboard/app/components/ui/alert/index.ts create mode 100644 clients/dashboard/app/components/ui/avatar/Avatar.vue create mode 100644 clients/dashboard/app/components/ui/avatar/AvatarBadge.vue create mode 100644 clients/dashboard/app/components/ui/avatar/AvatarFallback.vue create mode 100644 clients/dashboard/app/components/ui/avatar/AvatarGroup.vue create mode 100644 clients/dashboard/app/components/ui/avatar/AvatarGroupCount.vue create mode 100644 clients/dashboard/app/components/ui/avatar/AvatarImage.vue create mode 100644 clients/dashboard/app/components/ui/avatar/index.ts create mode 100644 clients/dashboard/app/components/ui/badge/Badge.vue create mode 100644 clients/dashboard/app/components/ui/badge/index.ts create mode 100644 clients/dashboard/app/components/ui/breadcrumb/Breadcrumb.vue create mode 100644 clients/dashboard/app/components/ui/breadcrumb/BreadcrumbEllipsis.vue create mode 100644 clients/dashboard/app/components/ui/breadcrumb/BreadcrumbItem.vue create mode 100644 clients/dashboard/app/components/ui/breadcrumb/BreadcrumbLink.vue create mode 100644 clients/dashboard/app/components/ui/breadcrumb/BreadcrumbList.vue create mode 100644 clients/dashboard/app/components/ui/breadcrumb/BreadcrumbPage.vue create mode 100644 clients/dashboard/app/components/ui/breadcrumb/BreadcrumbSeparator.vue create mode 100644 clients/dashboard/app/components/ui/breadcrumb/index.ts create mode 100644 clients/dashboard/app/components/ui/button-group/ButtonGroup.vue create mode 100644 clients/dashboard/app/components/ui/button-group/ButtonGroupSeparator.vue create mode 100644 clients/dashboard/app/components/ui/button-group/ButtonGroupText.vue create mode 100644 clients/dashboard/app/components/ui/button-group/index.ts create mode 100644 clients/dashboard/app/components/ui/button/Button.vue create mode 100644 clients/dashboard/app/components/ui/button/index.ts create mode 100644 clients/dashboard/app/components/ui/calendar/Calendar.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarCell.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarCellTrigger.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarGrid.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarGridBody.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarGridHead.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarGridRow.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarHeadCell.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarHeader.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarHeading.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarNextButton.vue create mode 100644 clients/dashboard/app/components/ui/calendar/CalendarPrevButton.vue create mode 100644 clients/dashboard/app/components/ui/calendar/index.ts create mode 100644 clients/dashboard/app/components/ui/card/Card.vue create mode 100644 clients/dashboard/app/components/ui/card/CardAction.vue create mode 100644 clients/dashboard/app/components/ui/card/CardContent.vue create mode 100644 clients/dashboard/app/components/ui/card/CardDescription.vue create mode 100644 clients/dashboard/app/components/ui/card/CardFooter.vue create mode 100644 clients/dashboard/app/components/ui/card/CardHeader.vue create mode 100644 clients/dashboard/app/components/ui/card/CardTitle.vue create mode 100644 clients/dashboard/app/components/ui/card/index.ts create mode 100644 clients/dashboard/app/components/ui/chart/ChartContainer.vue create mode 100644 clients/dashboard/app/components/ui/chart/ChartLegendContent.vue create mode 100644 clients/dashboard/app/components/ui/chart/ChartStyle.vue create mode 100644 clients/dashboard/app/components/ui/chart/ChartTooltipContent.vue create mode 100644 clients/dashboard/app/components/ui/chart/index.ts create mode 100644 clients/dashboard/app/components/ui/chart/utils.ts create mode 100644 clients/dashboard/app/components/ui/checkbox/Checkbox.vue create mode 100644 clients/dashboard/app/components/ui/checkbox/index.ts create mode 100644 clients/dashboard/app/components/ui/collapsible/Collapsible.vue create mode 100644 clients/dashboard/app/components/ui/collapsible/CollapsibleContent.vue create mode 100644 clients/dashboard/app/components/ui/collapsible/CollapsibleTrigger.vue create mode 100644 clients/dashboard/app/components/ui/collapsible/index.ts create mode 100644 clients/dashboard/app/components/ui/combobox/Combobox.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxAnchor.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxEmpty.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxGroup.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxInput.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxItem.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxItemIndicator.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxList.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxSeparator.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxTrigger.vue create mode 100644 clients/dashboard/app/components/ui/combobox/ComboboxViewport.vue create mode 100644 clients/dashboard/app/components/ui/combobox/index.ts create mode 100644 clients/dashboard/app/components/ui/command/Command.vue create mode 100644 clients/dashboard/app/components/ui/command/CommandDialog.vue create mode 100644 clients/dashboard/app/components/ui/command/CommandEmpty.vue create mode 100644 clients/dashboard/app/components/ui/command/CommandGroup.vue create mode 100644 clients/dashboard/app/components/ui/command/CommandInput.vue create mode 100644 clients/dashboard/app/components/ui/command/CommandItem.vue create mode 100644 clients/dashboard/app/components/ui/command/CommandList.vue create mode 100644 clients/dashboard/app/components/ui/command/CommandSeparator.vue create mode 100644 clients/dashboard/app/components/ui/command/CommandShortcut.vue create mode 100644 clients/dashboard/app/components/ui/command/index.ts create mode 100644 clients/dashboard/app/components/ui/dialog/Dialog.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogClose.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogContent.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogDescription.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogFooter.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogHeader.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogOverlay.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogScrollContent.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogTitle.vue create mode 100644 clients/dashboard/app/components/ui/dialog/DialogTrigger.vue create mode 100644 clients/dashboard/app/components/ui/dialog/index.ts create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenu.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuContent.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuGroup.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuItem.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuLabel.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuSub.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue create mode 100644 clients/dashboard/app/components/ui/dropdown-menu/index.ts create mode 100644 clients/dashboard/app/components/ui/empty/Empty.vue create mode 100644 clients/dashboard/app/components/ui/empty/EmptyContent.vue create mode 100644 clients/dashboard/app/components/ui/empty/EmptyDescription.vue create mode 100644 clients/dashboard/app/components/ui/empty/EmptyHeader.vue create mode 100644 clients/dashboard/app/components/ui/empty/EmptyMedia.vue create mode 100644 clients/dashboard/app/components/ui/empty/EmptyTitle.vue create mode 100644 clients/dashboard/app/components/ui/empty/index.ts create mode 100644 clients/dashboard/app/components/ui/field/Field.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldContent.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldDescription.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldError.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldGroup.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldLabel.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldLegend.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldSeparator.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldSet.vue create mode 100644 clients/dashboard/app/components/ui/field/FieldTitle.vue create mode 100644 clients/dashboard/app/components/ui/field/index.ts create mode 100644 clients/dashboard/app/components/ui/input-group/InputGroup.vue create mode 100644 clients/dashboard/app/components/ui/input-group/InputGroupAddon.vue create mode 100644 clients/dashboard/app/components/ui/input-group/InputGroupButton.vue create mode 100644 clients/dashboard/app/components/ui/input-group/InputGroupInput.vue create mode 100644 clients/dashboard/app/components/ui/input-group/InputGroupText.vue create mode 100644 clients/dashboard/app/components/ui/input-group/InputGroupTextarea.vue create mode 100644 clients/dashboard/app/components/ui/input-group/index.ts create mode 100644 clients/dashboard/app/components/ui/input/Input.vue create mode 100644 clients/dashboard/app/components/ui/input/index.ts create mode 100644 clients/dashboard/app/components/ui/item/Item.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemActions.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemContent.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemDescription.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemFooter.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemGroup.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemHeader.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemMedia.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemSeparator.vue create mode 100644 clients/dashboard/app/components/ui/item/ItemTitle.vue create mode 100644 clients/dashboard/app/components/ui/item/index.ts create mode 100644 clients/dashboard/app/components/ui/kbd/Kbd.vue create mode 100644 clients/dashboard/app/components/ui/kbd/KbdGroup.vue create mode 100644 clients/dashboard/app/components/ui/kbd/index.ts create mode 100644 clients/dashboard/app/components/ui/label/Label.vue create mode 100644 clients/dashboard/app/components/ui/label/index.ts create mode 100644 clients/dashboard/app/components/ui/native-select/NativeSelect.vue create mode 100644 clients/dashboard/app/components/ui/native-select/NativeSelectOptGroup.vue create mode 100644 clients/dashboard/app/components/ui/native-select/NativeSelectOption.vue create mode 100644 clients/dashboard/app/components/ui/native-select/index.ts create mode 100644 clients/dashboard/app/components/ui/number-field/NumberField.vue create mode 100644 clients/dashboard/app/components/ui/number-field/NumberFieldContent.vue create mode 100644 clients/dashboard/app/components/ui/number-field/NumberFieldDecrement.vue create mode 100644 clients/dashboard/app/components/ui/number-field/NumberFieldIncrement.vue create mode 100644 clients/dashboard/app/components/ui/number-field/NumberFieldInput.vue create mode 100644 clients/dashboard/app/components/ui/number-field/index.ts create mode 100644 clients/dashboard/app/components/ui/pagination/Pagination.vue create mode 100644 clients/dashboard/app/components/ui/pagination/PaginationContent.vue create mode 100644 clients/dashboard/app/components/ui/pagination/PaginationEllipsis.vue create mode 100644 clients/dashboard/app/components/ui/pagination/PaginationFirst.vue create mode 100644 clients/dashboard/app/components/ui/pagination/PaginationItem.vue create mode 100644 clients/dashboard/app/components/ui/pagination/PaginationLast.vue create mode 100644 clients/dashboard/app/components/ui/pagination/PaginationLink.vue create mode 100644 clients/dashboard/app/components/ui/pagination/PaginationNext.vue create mode 100644 clients/dashboard/app/components/ui/pagination/PaginationPrevious.vue create mode 100644 clients/dashboard/app/components/ui/pagination/index.ts create mode 100644 clients/dashboard/app/components/ui/popover/Popover.vue create mode 100644 clients/dashboard/app/components/ui/popover/PopoverAnchor.vue create mode 100644 clients/dashboard/app/components/ui/popover/PopoverContent.vue create mode 100644 clients/dashboard/app/components/ui/popover/PopoverDescription.vue create mode 100644 clients/dashboard/app/components/ui/popover/PopoverHeader.vue create mode 100644 clients/dashboard/app/components/ui/popover/PopoverTitle.vue create mode 100644 clients/dashboard/app/components/ui/popover/PopoverTrigger.vue create mode 100644 clients/dashboard/app/components/ui/popover/index.ts create mode 100644 clients/dashboard/app/components/ui/progress/Progress.vue create mode 100644 clients/dashboard/app/components/ui/progress/index.ts create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendar.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarCell.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarCellTrigger.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarGrid.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarGridBody.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarGridHead.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarGridRow.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarHeadCell.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarHeader.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarHeading.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarNextButton.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/RangeCalendarPrevButton.vue create mode 100644 clients/dashboard/app/components/ui/range-calendar/index.ts create mode 100644 clients/dashboard/app/components/ui/scroll-area/ScrollArea.vue create mode 100644 clients/dashboard/app/components/ui/scroll-area/ScrollBar.vue create mode 100644 clients/dashboard/app/components/ui/scroll-area/index.ts create mode 100644 clients/dashboard/app/components/ui/select/Select.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectContent.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectGroup.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectItem.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectItemText.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectLabel.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectScrollDownButton.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectScrollUpButton.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectSeparator.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectTrigger.vue create mode 100644 clients/dashboard/app/components/ui/select/SelectValue.vue create mode 100644 clients/dashboard/app/components/ui/select/index.ts create mode 100644 clients/dashboard/app/components/ui/separator/Separator.vue create mode 100644 clients/dashboard/app/components/ui/separator/index.ts create mode 100644 clients/dashboard/app/components/ui/sheet/Sheet.vue create mode 100644 clients/dashboard/app/components/ui/sheet/SheetClose.vue create mode 100644 clients/dashboard/app/components/ui/sheet/SheetContent.vue create mode 100644 clients/dashboard/app/components/ui/sheet/SheetDescription.vue create mode 100644 clients/dashboard/app/components/ui/sheet/SheetFooter.vue create mode 100644 clients/dashboard/app/components/ui/sheet/SheetHeader.vue create mode 100644 clients/dashboard/app/components/ui/sheet/SheetOverlay.vue create mode 100644 clients/dashboard/app/components/ui/sheet/SheetTitle.vue create mode 100644 clients/dashboard/app/components/ui/sheet/SheetTrigger.vue create mode 100644 clients/dashboard/app/components/ui/sheet/index.ts create mode 100644 clients/dashboard/app/components/ui/sidebar/Sidebar.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarContent.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarFooter.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarGroup.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarGroupAction.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarGroupContent.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarGroupLabel.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarHeader.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarInput.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarInset.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenu.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuAction.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuBadge.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuButton.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuButtonChild.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuItem.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuSkeleton.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuSub.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuSubButton.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarMenuSubItem.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarProvider.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarRail.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarSeparator.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/SidebarTrigger.vue create mode 100644 clients/dashboard/app/components/ui/sidebar/index.ts create mode 100644 clients/dashboard/app/components/ui/sidebar/utils.ts create mode 100644 clients/dashboard/app/components/ui/skeleton/Skeleton.vue create mode 100644 clients/dashboard/app/components/ui/skeleton/index.ts create mode 100644 clients/dashboard/app/components/ui/sonner/Sonner.vue create mode 100644 clients/dashboard/app/components/ui/sonner/index.ts create mode 100644 clients/dashboard/app/components/ui/spinner/Spinner.vue create mode 100644 clients/dashboard/app/components/ui/spinner/index.ts create mode 100644 clients/dashboard/app/components/ui/switch/Switch.vue create mode 100644 clients/dashboard/app/components/ui/switch/index.ts create mode 100644 clients/dashboard/app/components/ui/table/Table.vue create mode 100644 clients/dashboard/app/components/ui/table/TableBody.vue create mode 100644 clients/dashboard/app/components/ui/table/TableCaption.vue create mode 100644 clients/dashboard/app/components/ui/table/TableCell.vue create mode 100644 clients/dashboard/app/components/ui/table/TableEmpty.vue create mode 100644 clients/dashboard/app/components/ui/table/TableFooter.vue create mode 100644 clients/dashboard/app/components/ui/table/TableHead.vue create mode 100644 clients/dashboard/app/components/ui/table/TableHeader.vue create mode 100644 clients/dashboard/app/components/ui/table/TableRow.vue create mode 100644 clients/dashboard/app/components/ui/table/index.ts create mode 100644 clients/dashboard/app/components/ui/tabs/Tabs.vue create mode 100644 clients/dashboard/app/components/ui/tabs/TabsContent.vue create mode 100644 clients/dashboard/app/components/ui/tabs/TabsList.vue create mode 100644 clients/dashboard/app/components/ui/tabs/TabsTrigger.vue create mode 100644 clients/dashboard/app/components/ui/tabs/index.ts create mode 100644 clients/dashboard/app/components/ui/tags-input/TagsInput.vue create mode 100644 clients/dashboard/app/components/ui/tags-input/TagsInputInput.vue create mode 100644 clients/dashboard/app/components/ui/tags-input/TagsInputItem.vue create mode 100644 clients/dashboard/app/components/ui/tags-input/TagsInputItemDelete.vue create mode 100644 clients/dashboard/app/components/ui/tags-input/TagsInputItemText.vue create mode 100644 clients/dashboard/app/components/ui/tags-input/index.ts create mode 100644 clients/dashboard/app/components/ui/textarea/Textarea.vue create mode 100644 clients/dashboard/app/components/ui/textarea/index.ts create mode 100644 clients/dashboard/app/components/ui/toggle-group/ToggleGroup.vue create mode 100644 clients/dashboard/app/components/ui/toggle-group/ToggleGroupItem.vue create mode 100644 clients/dashboard/app/components/ui/toggle-group/index.ts create mode 100644 clients/dashboard/app/components/ui/toggle/Toggle.vue create mode 100644 clients/dashboard/app/components/ui/toggle/index.ts create mode 100644 clients/dashboard/app/components/ui/tooltip/Tooltip.vue create mode 100644 clients/dashboard/app/components/ui/tooltip/TooltipContent.vue create mode 100644 clients/dashboard/app/components/ui/tooltip/TooltipProvider.vue create mode 100644 clients/dashboard/app/components/ui/tooltip/TooltipTrigger.vue create mode 100644 clients/dashboard/app/components/ui/tooltip/index.ts create mode 100644 clients/dashboard/app/composables/useAccent.ts create mode 100644 clients/dashboard/app/composables/useCommandPalette.ts create mode 100644 clients/dashboard/app/composables/useNavigation.ts create mode 100644 clients/dashboard/app/lib/utils.ts create mode 100644 clients/dashboard/components.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..5ad9f09 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "shadcn-vue": { + "command": "npx", + "args": ["-y", "shadcn-vue@2.8.2", "mcp", "-c", "clients/dashboard"] + } + } +} diff --git a/README.md b/README.md index cc06231..1d2a9fe 100644 --- a/README.md +++ b/README.md @@ -1301,13 +1301,15 @@ src/DotNetBoostSample.Api/ net10.0 — the host, and nothing else aspire/DotNetBoostSample.AppHost/ postgres + api + dashboard aspire/DotNetBoostSample.ServiceDefaults/ telemetry, health, discovery -clients/dashboard/ Nuxt 4 + Nuxt UI, sidebar shell, RTL-aware +clients/dashboard/ Nuxt 4 + shadcn-vue, sidebar shell, RTL-aware + components.json shadcn-vue's CLI config — `npx shadcn-vue add ` + app/components/ui/ shadcn-vue's components, copied in by that CLI test/nuxt/ Vitest over the composables — `npm test` i18n/locales/{en,ar,fr}.json the dashboard's own labels, one file per locale i18n/i18n.config.ts CLDR plural rules for Arabic and French app/plugins/ui-language.ts moves the UI locale with the reading language app/plugins/reading-language.ts restores the remembered language before the first paint - app/composables/useShellLocale.ts direction and Nuxt UI's strings, from the API's answer + app/composables/useShellLocale.ts the shell's direction, from the API's answer app/utils/locale.ts the locale, for helpers that cannot call useI18n() app/utils/rememberedLanguage.ts the choice, and its direction, across visits app/pages/settings.vue tabbed shell — one tab per settings group diff --git a/clients/dashboard/CLAUDE.md b/clients/dashboard/CLAUDE.md new file mode 100644 index 0000000..c278472 --- /dev/null +++ b/clients/dashboard/CLAUDE.md @@ -0,0 +1,39 @@ +# Dashboard UI + +Nuxt 4 SPA on **shadcn-vue** (`reka-vega` style, zinc base, lucide icons). The shell follows the +`dashboard-01` and `sidebar-07` blocks: an inset sidebar that collapses to icons, a sticky page +header, ⌘K command palette. + +## Components + +- Primitives live in `app/components/ui/` and are **added with the CLI, never hand-written**: + `npx shadcn-vue@latest add `. They are auto-imported without a prefix (`