Status: approved 2026-09-25 (decisions below confirmed) · Branch: migration/wasm · 2026-09-25
- The server becomes an API host: everything under
/api/**, plus SignalR (/hubs/**), health, and exactly one server-rendered page — the initial owner setup, rewritten as a plain Razor Page. - All Admin UI moves to a new standalone Blazor WASM app
DevCoreApp.Client.Desktop. The existingDevCoreApp.Clientis renamedDevCoreApp.Client.Mobile. - Controllers are thin: one service call per action, WebServiceToolkit
HandleWebRequestAsync, common error handling in one place, no mapping/validation in the controller (any exception carries a comment explaining why). - Client pages hold no business logic — they call client service classes (
[BlazorService],IApiContext<T>,IServiceExecutionHost) from BlazorToolkit. - All date/time values cross the wire as UTC. Only the client converts to local, and only for display (and back to UTC on input).
- A migration instruction doc for ThreadIQ, Tentrie and future forks.
ThreadIQ does expose an /api layer for its mobile app, but it is only partly the pattern we want:
| Aspect | ThreadIQ today | Take it? |
|---|---|---|
| Routes | Literal [Route("api/...")], no versioning, no global prefix |
Yes |
| Base controller | CrmCrudControllerBase<TItem> over ICRUDService<T> (list/get/add/update/delete); subclasses only override Service |
Yes — generalize into Core |
| Controller body | HandleWebRequestAsync, one service call (two small exceptions: InteractionsController branches between two services, ChatController builds a request) |
Yes, without the exceptions |
| Response shape | Inconsistent: CRM controllers return the whole ServiceActionResult<T> envelope (for the offline CacheableSource); Core controllers return .Result |
Pick one — see D2 |
| Authorization | Bare [Authorize] everywhere (its own plan records this as a gap) |
No — keep permission policies |
| Auth | JWT + rotating refresh tokens (`api/auth/login | refresh |
| Client calls | Raw HttpClient; IHttpApiContextFactory is registered but no service uses IApiContext<T> |
No — use BlazorToolkit IApiContext<T> |
| Dates | Not UTC. Server decorators convert to the profile time zone (ToLocal(UserTimeZone)); the client treats values as already local |
No — this is exactly what we are replacing |
| Hosting | Client published into wwwroot/mobile, served by UseBlazorFrameworkFiles("/mobile") + MapFallbackToFile; CORS policy WasmClient from Cors:AllowedOrigins |
Yes (mobile), adapted for desktop |
| OpenAPI | None | Out of scope; can be added later |
- ~20 Admin pages and 6 Account pages, all Blazor Server (the Account pages are static SSR).
Services have no Blazor/circuit dependencies (no
NavigationManager,IJSRuntime,AuthenticationStateProvider), so they can be called from controllers as they are. The one exception isAccountService, which usesSignInManager(cookie) andIHttpContextAccessor, and whose callers pass absolute link bases built withNavigationManager. - Controllers exist only for auth, files, profile pictures, part of import/export, and the user profile. There are no controllers for roles, organizations, email log, jobs, audit log, feature flags, API keys, webhooks, settings, theme, notifications, grid profiles, account flows, or current-user info.
- Error contract mismatch. BlazorToolkit's
HttpApiContextreads error bodies as aServiceActionError(ErrorType,Message,PropertyName). WebServiceToolkit'sHandleWebRequestAsyncreturnsNotFound()/Conflict()with no body andBadRequest(string), andApiExceptionHandlerwritesApiErrorResponse {Status, Message, CorrelationId}. None of these are what the client parses, so they must be aligned (D2). - Service failures are return values, not exceptions. A
ServiceActionResult.Failed(...)returned to a controller must be turned into an HTTP error somewhere. It belongs in one shared place (the base controller), not in each action. - Permissions never reach a client. The JWT carries
sub,emailand roles only. Permission claims are added per request byPermissionClaimsTransformation. AGET /api/meendpoint is needed. - Dates: every DTO uses
DateTime, services writeDateTime.UtcNow, and the UI renders raw UTC. That makes it the easiest of the three repos to convert. - Existing client (
DevCoreApp.Client): template leftovers;PersistentAuthenticationStateProvideris always anonymous in a standalone app; the HttpClient name registered inProgram.csdoes not match the name passed toHttpApiContextFactory; no service assembly is registered.Client.Serviceshas a genericCRUDService<T>overIApiContext<T>and aNotificationHubClientwith no access-token provider. - Bugs to fix along the way:
UserProfileControllerreturns the envelope instead of.Result, and putsAdmin.Users.Viewon "my profile".copyTextToClipboardis called fromEditUserbut never defined.AccountService.LoginAsyncskips the status and lockout checks thatJwtAuthServicemakes.ProfilePictureControllerhas no per-user authorization check.- Mock mode lacks
IOrganizationService. - The
user/settingslink inMainLayouthas no page. - The cookie is configured with
HttpOnly=false.
D1 — Hosting. WebService serves Desktop at / (same origin → no CORS, no token leakage
across origins) and Mobile at /mobile (the ThreadIQ MobileClientHosting pattern, moved to
Core). Both are published into wwwroot/ by the build rather than referenced as projects, so the
server stays free of client dependencies (the ThreadIQ model). CORS stays configurable
(Cors:AllowedOrigins) for clients served from another origin.
D2 — Wire contract.
- Success → bare
T(whatIApiContext<T>deserializes). No envelope on the wire. - Error → HTTP status + a
ServiceActionErrorJSON body (the type the client already parses), withCorrelationIdadded via ProblemDetails-style extension or a derived type inShared.Model. - Shared plumbing lives in a new
Core/Controllers/ApiControllerBase:HandleServiceAsync(() => service.X(...))awaits aServiceActionResult<T>and returns.Resulton success. On failure it mapsErrorType/IsAuthorizedto 400/401/403/404/409/422.- Every action calls this helper. That is the "common error handling"; the controller itself stays one line.
ApiExceptionHandlerwrites the sameServiceActionErrorshape for thrown exceptions.
- ThreadIQ's mobile CRM endpoints return the envelope for BlazorToolkit
CacheableSource. Decided: changeCacheableSourceto read bareT(a BlazorToolkit change), so ThreadIQ drops the envelope too.
D3 — Authentication. JWT access token + rotating refresh token (existing JwtAuthService), for
both clients. The client uses AuthTokenHandler (DelegatingHandler, refresh-once-on-401 as in
ThreadIQ) and a JwtAuthenticationStateProvider fed by GET /api/me. Tokens live in memory, with
the refresh token persisted in localStorage.
- Decided: JWT for both clients — one auth model.
- The Blazor Server cookie UI path (
SignInManagerinAccountService, Identity razor components,IdentityRevalidatingAuthenticationStateProvider, logout minimal API) is removed. - The Smart scheme stays: Bearer /
X-Api-Key/ cookie. Cookie is still needed only if the setup page signs in, which D5 avoids, so the cookie branch can go.
D4 — Authorization. Controllers carry the same [Authorize(Policy = "...")] the pages carry
today; the server is the only enforcement point. GET /api/me returns
{ profile, roles, permissions[], organizations, timeZoneId, theme } so the client can hide what
the user can't do (a client PolicyAuthorizationHandler that reads the permission list, so
<AuthorizeView Policy="..."> keeps working unchanged in ported pages).
D5 — Owner setup page.
Pages/Setup.cshtml(Razor Page, anonymous) at/setup.- Returns 404 once
HasUsersAsync()is true; the service re-checks insideSetupOwnerAsyncas it does today. - Posts to
AccountService.SetupOwnerAsyncwithout signing in, then redirects to/loginon the Desktop client. - Antiforgery on, and a
TimeZoneIdfield (defaulted from the browser via a tiny script). - Decided: the "no users exist yet" gate is sufficient; no setup token.
D6 — Date/time.
- DTOs keep
DateTime(noDateTimeOffsetchurn). - A
UtcDateTimeJsonConverterinShared.Utilsis registered in both serverAddControllers().AddJsonOptionsand clientJsonSerializerOptions:- it writes ISO-8601 with
Z; - on read it forces
Kind=Utc; Kind=Localvalues are converted to UTC on write; offset-less values are read as UTC (implemented:Shared.Utils/Core/Json/UtcDateTimeJsonConverter.cs).
- it writes ISO-8601 with
- Server rule: services and decorators never convert to a user's zone.
BaseService.UserTimeZoneandICurrentUserContext.NowLocalstay only for non-wire uses (email text, report file names). Date-only values such as a due date useDateOnly. - Client:
ILocalTimeServiceresolves the zone: the profileTimeZoneIdif set, else the browser zone (TimeZoneInfo.Localis the browser's in WASM).- Display goes through
<LocalTime Value="..." Format="g" />andToLocal()helpers. - Date inputs convert local→UTC inside the client service before sending, never in pages.
- Query parameters (e.g. audit log date ranges) are UTC too.
D7 — Project layout. The user text said "Clients folder"; the repo uses src/Client/, so it
stays:
src/Client/
├── Client.Services/ (existing) → DevInstance.DevCoreApp.Client.Services
│ └── Core/<Feature>/ API client services shared by Desktop + Mobile
├── DevCoreApp.Client.Desktop/ (new) → DevInstance.DevCoreApp.Client.Desktop
│ ├── Core/UI/{Layout,Components,Pages/<Feature>}, Core/Auth, Core/Time
│ ├── App/ (empty marker)
│ └── Program.cs, UI/App.razor, _Imports.razor, wwwroot/, Styles/, Scripts/
└── DevCoreApp.Client.Mobile/ (renamed from DevCoreApp.Client) → DevInstance.DevCoreApp.Client.Mobile
- Shared UI (HDataGrid, PermissionGrid…) starts in Desktop. A
Client.UIRazor class library is extracted only when Mobile actually needs a component. - SCSS/TS build (DartSassBuilder,
tsc) andapp.js/theme.jsmove to Desktop.reconnectModalis deleted.
D8 — Mocks. Server mocks ([BlazorServiceMock] in mocks/Server/...) stay for API-level dev.
New client mocks mocks/Client/Desktop.ServicesMocks implement the client service interfaces,
so dotnet run -c ServiceMocks on Desktop needs no server at all. Most existing mock data
generators (Bogus) can be moved over.
Each is Core/Controllers/<Name>Controller.cs : ApiControllerBase; one service method per action.
List endpoints take a [QueryModel] (WebServiceToolkit) instead of loose top/page/sortBy/search.
| Route | Service | Notes |
|---|---|---|
api/auth (login, refresh, revoke) |
IJwtAuthService |
exists; IP/UA read in controller — comment why |
api/account (register, forgot-password, reset-password, confirm-email, set-password) |
IAccountService (new interface) |
link bases built in service from App:PublicBaseUrl config, not from the request |
api/me (GET, PUT profile, GET/PUT theme, GET permissions) |
ICurrentUserService (new, wraps profile + permissions + theme) |
replaces UserProfileController |
api/users + sub-resources (/{id}/organizations, /permission-overrides, /effective-permissions, /access-state, /resend-invitation, /password, /profile-picture) |
IUserProfileService |
picture endpoints fold in ProfilePictureController, and gain an authorization check |
api/roles (+ /{id}/permissions), api/permissions |
IRoleManagementService |
|
api/organizations (+ /tree, /current, /{id}/toggle-active, /{id}/move) |
IOrganizationService |
|
api/email-logs (+ /{id}/resend, /resend-failed, DELETE ?ids=) |
IEmailLogService |
|
api/jobs (+ /{id}/logs, /{id}/cancel, /{id}/retry) |
IJobDashboardService |
|
api/audit-logs |
IAuditLogService |
|
api/feature-flags · api/api-keys · api/webhooks (+ /{id}/deliveries) · api/settings |
respective admin services | |
api/import-export (+ commit, rollback, session, entity-types, fields) |
IImportExportService |
validate binds the multipart ImportValidateForm (done; was a mappingsJson field deserialized in the controller) |
api/notifications (list, unread-count, mark-read, mark-all-read) |
INotificationService |
current-user overloads needed (today takes a Guid) |
api/grid-profiles/{grid} |
IGridProfileService (new interface) |
|
api/files |
IFileService |
exists; download returns File — the one documented non-JSON action |
Service changes required: interfaces for AccountService and GridProfileService;
AccountService returns ServiceActionResult<T> and drops SignInManager/cookie sign-in; sync
methods (GetCurrentUser, GetAvailableRoles, GetImportFields…) become async-returning SAR
where exposed; user-scoped notification methods resolve the user from AuthorizationContext
instead of a parameter.
Each phase ends green (dotnet build, dotnet test) and runnable.
Phase 0 — Groundwork (server, no UI change) — ✅ done (#1179)
- ✅ WebServiceToolkit 10.3.0: every
HandleWebRequestAsyncerror returns aWebServiceErrorbody (wire-compatible withServiceActionError);WebServiceExceptionbase withForbiddenException(403) /UnprocessableEntityException(422); no stack traces to clients;AddWebServiceToolkitErrors()for model-validation 400s. - ✅
Core/Controllers/ApiControllerBase—HandleServiceAsync/HandleServiceunwrapServiceActionResult<T>(failed result → 403/400/422/500). All existing controllers moved onto it.ApiExceptionHandlerusesControllerUtils.ToWebServiceError+CorrelationId.BusinessRuleExceptionderives fromUnprocessableEntityException. - ✅
UtcDateTimeJsonConverter(Shared.Utils) registered on controllers, with tests. - ✅
Core/Hosting/WasmClientHosting—WasmClients:{Desktop,Mobile}:BasePath(empty = off;/rejected until Phase 4) andCors:AllowedOrigins(empty = same-origin only). - ✅ Bugs fixed:
UserProfileControllerreturned the envelope and requiredAdmin.Users.Viewfor "my profile"; profile-picture upload/delete had no ownership check (now self orAdmin.Users.Edit, enforced in the service); missingcopyTextToClipboard; auth cookieHttpOnly=false. Deferred:AccountService.LoginAsyncstatus/lockout gap (goes away with cookie sign-in in Phase 4);IOrganizationServicemock (with the Phase 1 organizations controller);user/settingslink (Phase 3 layout port). Noted, unchanged:api/auth/login|refreshreport failure as 200 +succeeded:false(existing JWT contract the ThreadIQ mobile client relies on) — revisit with the Phase 2 auth client.
Phase 1 — API layer — ✅ done (Blazor Server UI still works in parallel on the same services)
- ✅
ModelList<T>/ModelItem(obsolete in WebServiceToolkit 10.3.1) →Shared.Model/Core/Common/PagedList<T>(IModelList<T>, same JSON) and DTOs implementingIModelItem. - ✅ Query models:
ListQuery(top,page,sortBy=-Field,Other,search),DateRangeListQuery(dates normalized to UTC), andAuditLogQuery,JobQuery,EmailLogQuery,OrganizationQuery,SettingQuery. There is no+sort prefix, because it decodes to a space in a query string. - ✅ Service changes:
IAccountService(register / forgot / reset / confirm-email / invitation set-password, JWT-era: no cookie sign-in, failures → 400, links fromIAccountLinkBuilder). The anonymous set-password re-verifies the emailed token.ICurrentUserService→CurrentUserItem(profile, roles, effective permissions, theme).IGridProfileService.- Current-user notification methods.
RequiresOrganizationSelection→ServiceActionResult<bool>.
- ✅ Controllers (all
ApiControllerBase, one service call per action, permission per verb:GET=View,POST=Create,PUT=Edit,DELETE=Delete/Revoke; Owner/Admin hold all):api/account,api/me(+/profile,/theme),api/users(+ roles, organizations, permission-overrides, effective-permissions, access-state, resend-invitation, password),api/roles,api/permissions,api/organizations(+ tree, current, toggle-active, move),api/email-logs(+ bulk-delete, resend, resend-failed),api/jobs(+ logs, cancel, retry),api/audit-logs,api/feature-flags,api/api-keys(+ revoke),api/webhooks(+ deliveries),api/settings,api/notifications(+ unread-count, read, read-all),api/grid-profiles/{grid},api/import-export(+ entity types, fields, session, commit, rollback).api/user/profilewas removed in favour ofapi/me/profile. - ✅
AddApiControllers()in one place: bare results,WebServiceErrorbodies, UTC dates,[QueryModel]binding, andnullreturned as JSONnullrather than an empty 204. - ✅ Contract tests (
tests/Server/WebService/Core/Api/ApiContractTests.cs): an in-memory host covers success,null, every error mapping, and exceptions escaping a controller. Errors are read as BlazorToolkitServiceActionError. It also covers query binding and UTC dates. Per-controller smoke tests need an authenticated DB-backed host, so they are deferred to Phase 2 together with the login client. - ✅
IOrganizationServicemock.
Bugs found and fixed along the way:
- WebServiceToolkit 10.3.2: the
[QueryModel]binder split everystringinto chars (anysearch=gave 400), and missed properties typedIEnumerable<T>. ApiExceptionHandlernever matched in production. WithUseExceptionHandler("/Error"), .NET 10 rewritesRequest.Pathto/ErrorbeforeIExceptionHandlers run, so API errors thrown outside a controller got the HTML error page. It now readsIExceptionHandlerPathFeature.Path. It also needsAllowStatusCode404Response = true, or a 404 from the handler is rethrown. Forks (ThreadIQ) have the same bug.NotificationService.MarkAsReadAsyncdid not check ownership, so any user in the organization could mark a colleague's notification as read.GridProfileService.SaveAsyncthrew 401 for a permission failure; it now throws 403. A 401 makes a JWT client refresh and retry.
Dependency changes:
- BlazorToolkit 10.1.2 → 10.5.0 (its
ModelDataPagertakesIModelList<T>). 10.4.0 insertedException=1intoServiceActionErrorType, the numberingWebServiceErrorTypematches. Every client must use BlazorToolkit ≥ 10.4.0, or error types are misread. - WebServiceToolkit 10.3.2 (query-binder fix).
- All
Microsoft.*packages 10.0.3 → 10.0.12, which BlazorToolkit 10.5.0 requires. AddBlazorServicesregisters a class only under its interfaces once it has any. The pages that injectAccountService/GridProfileServiceby concrete type get forwarding registrations inProgram.csuntil Phase 4.
Open for Phase 2:
- Profile pictures:
api/users/{id}/profile-picturerequires auth, but a WASM<img src>sends no bearer token. Either fetch the image through the client service as a blob/data URL, or serve pictures through short-lived signed URLs. Import validate still deserializesFixed: see the Phase 5 follow-ups. Revisit with the import client.mappingsJsonfrom a form field in the controller.
Phase 2 — Client foundation — ✅ done (Desktop verified signed-in; Mobile signed-in flow to be checked by hand)
- ✅
DevCoreApp.Client→DevCoreApp.Client.Mobile(folder, csproj, namespaces, slnx). Template leftovers were removed (counter, weather,PersistentAuthenticationStateProvider,UserInfo, emptyNetApistubs), as were the deadtests/Client/Client.ClientMocks. - ✅
Client.Services/Coreshared by both clients (AddDevCoreClientServices(apiBase)):Api/:ApiServiceBase(BlazorToolkitIApiContext→ServiceActionResult) andApiQueryExtensions. BlazorToolkit's URL builder neither escapes values nor formats them culture-invariantly, so query models are encoded here, with UTC ISO dates and comma-joined arrays.Auth/:AuthTokenStore(a singleton persisted tolocalStorage) andTokenRefresher(single-flight, because the server treats reuse of a rotated refresh token as theft).AuthTokenHandlerrefreshes near expiry, then refreshes once on 401 and replays the request with its body. AlsoApiAuthenticationStateProvider(claims fromapi/me) andClientPermissionPolicyProvider, so<AuthorizeView Policy="Module.Entity.Action">works unchanged.Time/ILocalTimeService: the profileTimeZoneId, falling back to the browser zone; DST-safe.Me/,Users/IProfilePictureService: pictures are fetched with the token and returned asdata:URLs, which resolves the<img src>problem.Notifications/: the hub client connects to the API origin withAccessTokenProvider, andINotificationServicecallsapi/notifications.
- ✅
DevCoreApp.Client.Desktop:- the shell (layout, sidebar, top bar, theme toggle synced with
api/me, live unread badge); AuthorizeRouteView→RedirectToLogin;- Login (open-redirect-safe
returnUrl), Home and Profile (with a time-zone picker); - the
<LocalTime>component; - SCSS/TS pipeline copied from WebService.
It runs standalone on :5280 (
ApiBaseUrl).
- the shell (layout, sidebar, top bar, theme toggle synced with
- ✅ Mobile: Login, Home and Profile over the same services, running standalone on :5290. This proves the shared layer.
- ✅ Server:
- Dev
Cors:AllowedOriginsfor :5280/:5290/:7280/:7290. PermissionClaims.Typemoved toShared.Model, so client and server share it.UpdateCurrentUserAsyncno longer lets a user change their own email.- The
DartSassBuildercopy target was fixed. It hooked a non-existent target, so the server'swwwroot/app.csshad never been refreshed by the build.
- Dev
- ✅
tests/Client/Client.Services.Testshas 19 tests: query encoding, local time and DST, the token handler (bearer, refresh-and-replay, expiry, rejected refresh → sign-out, concurrent 401s → one refresh), and claims and policies. - Checked live in a browser:
- Desktop and Mobile load and redirect protected routes to login with
returnUrl; - the cross-origin preflight passes;
- a wrong password shows the server's error;
- signed in on Desktop with a real account:
api/auth/loginreturned 200 and the user was sent back to thereturnUrl;api/mereturned 200 and the name shows in the top bar;- the unread count loaded and the notification hub connected;
- profile save (
PUT api/me/profile) returned 200; - after choosing a time zone, the page re-read
api/meand the display zone switched (the zone was restored afterwards); - after a page reload the session was restored from
localStorage.
- The live run exposed a pre-existing server bug, now fixed. The "Smart" scheme selector
sent
/hubsrequests carrying?access_token=to the cookie scheme. Browsers cannot put a header on a WebSocket, so every WebSocket upgrade got a 302 and SignalR silently fell back to long polling./hubswithaccess_tokennow selects JwtBearer. Forks have the same selector. - Not checked:
- the Mobile signed-in flow (the browser tool could not drive the Mobile tab; it uses the same services as Desktop);
- a token refresh after the 15-minute expiry (covered by the handler tests).
- Desktop and Mobile load and redirect protected routes to login with
- ✅ Real-time notifications are optional.
Notifications:RealTime(defaulttrue) controls whether the hub is mapped at all, andapi/mereturns it asRealTimeNotifications. The sharedIUnreadNotificationsMonitorkeeps the unread count current in every hosting setup:- it loads the count over REST;
- it connects the hub only when the server offers it;
- it polls every 60 s whenever the hub is not connected (disabled, failed, or dropped);
- it re-syncs after a reconnect;
- Desktop also refreshes when the window regains focus.
Set
RealTimetofalseon hosts that cannot hold connections open, or when running several instances without a SignalR backplane (RedisAddStackExchangeRedis, or Azure SignalR Service), because a push from one instance never reaches clients connected to another. Unauthenticated/hubsrequests now get 401 instead of the cookie login redirect. 7 tests cover the monitor.
- ⏭ The client mocks project (D8) moves to Phase 3, where there are feature services to mock.
Phase 3 — Port pages — ✅ done
- ✅ Client services in
Client.Services/Core/<Feature>for every admin feature: API keys, audit log, jobs, email log, feature flags, grid profiles, import/export, organizations, roles, settings, users, webhooks and account. Their interfaces mirror the server interfaces (same names and signatures), so pages ported by swappingusings. The server's synchronous methods are…Asyncon the client, because they are HTTP calls. - ✅ All admin pages and grid components were ported to
Client.Desktop/Core/UI:- pages: users (list/new/edit), roles, organizations, email log (+ detail), jobs, audit log, import, feature flags, API keys, webhooks (+ deliveries), settings;
- components: HDataGrid & co, permission grids, export dialog. The nav shows each item only with the permission its API needs.
- ✅ Account pages: register, forgot/reset password, confirm email → invitation password (over
api/accounton the auth client, no token), plus links from Login. - ✅ Dates: every UTC value that was printed raw now goes through
LocalClock/<LocalTime>(31 sites). Date filters are converted to UTC inside the client services. - ✅ Profile pictures:
ProfilePictureUploadloads throughIProfilePictureService(a token fetch returned as adata:URL, cached and invalidated on upload/delete). - ✅ Client mocks:
mocks/Client/Client.Services.Mocks, ported from the server mocks, plus auth/me/grid/notifications/pictures/account mocks.dotnet run -c ServiceMockson Desktop runs with no server; any credentials sign in as an Owner. - ✅ Verified live with a real account: every admin page loads, all API calls returned 200, and the session refresh happened on its own. Mock mode was verified with the server stopped.
- Found during verification:
- Audit trigger timestamps were wrong (pre-existing). The Postgres
audit_trigger_function()storedNOW() AT TIME ZONE 'UTC'into atimestamptzcolumn, so every database-sourced audit row is shifted by the DB session's UTC offset. The helper inAuditTriggerExtensionsis fixed. A new migration that callsmigrationBuilder.CreateAuditTriggerFunction()is needed to update existing databases. Rows already written stay shifted. - The unread badge fetched twice per load (the hub reports its first connect as a change); that is fixed and tested.
- Audit trigger timestamps were wrong (pre-existing). The Postgres
- Open:
the login page should redirect when already signed in(done, see Phase 5 follow-ups);Visual Studio multi-project launch(done:DevInstance.DevCoreApp.slnLaunch);- write flows (create/edit/delete) were exercised only through the API contract, not clicked through in the browser.
Phase 4 — Cut over — ✅ done
- ✅ Blazor Server is removed. Gone from WebService:
Core/UI/**,UI/App.razorandRoutes.razor,_Imports.razor;- the Identity Razor components and endpoints (
IdentityRedirectManager, the revalidating auth-state provider,MapAdditionalIdentityEndpoints, the no-op email sender); - the SCSS/TS pipeline and static assets, which now live in Desktop;
- the Blazor Components packages, except
WebAssembly.Server.
- ✅ Owner setup:
Core/Pages/Setup.cshtml(Razor Pages,RootDirectory = /Core/Pages) at/setup:- anonymous, and 404 once any user exists (the service re-checks on submit, returning 403);
- antiforgery, with the time zone taken from the browser;
- no sign-in: it redirects to the client login.
The Desktop login shows a "set up the owner account" link while
api/account/setup-requiredis true./Erroris a Razor Page too.
- ✅
AccountServicenow implements onlyIAccountService(the API flows plusIsSetupRequiredAsync/SetupOwnerAsync). All cookie sign-in code is gone, as are 6 DTOs only the old pages used. The Smart scheme falls back to JWT instead of the Identity cookie, so anonymous requests get a 401, never a login redirect. - ✅ Hosting (decision revisited: project references, not a publish script). WebService
references both clients, which makes them static web assets on build, run and publish, and one
F5 runs everything; this fixes the Visual Studio launch issue.
- Desktop is at
/; Mobile is at/mobile(StaticWebAssetBasePath,<base href="/mobile/">). - They are served by
MapStaticAssets()plusMapWasmClients()(index.html fallbacks, with/api,/hubsand/healthexcluded). - ⚠
UseBlazorFrameworkFilesmust not be used withMapStaticAssets. It branches the pipeline for/_frameworkand those requests end in a 500 ("reached the end of the pipeline without executing the endpoint"). - Hosted clients call their own origin (
ApiBaseUrlempty). Standalone client dev maps the dev-server origin to the Api host inappsettings.Development.json(DevServers,ApiBaseAddress). AStandalonelaunch environment was tried first and never worked: the .NET 10 WASM SDK bakes the environment into the build and ignoresASPNETCORE_ENVIRONMENT.
- Desktop is at
- ✅ Verified with the server alone:
/and deep links serve Desktop;/mobile/…serves Mobile; the runtimes are served;/setupreturns 404 because users exist;/Errorrenders;- unknown
/api,/hubsand/healthURLs return 404; - sign-in on Desktop → Users worked same-origin (no preflights; all calls 200);
- Mobile shares the session;
- the server logged no errors. The setup flow was tested end to end on a throwaway database (since dropped):
/setuprendered and antiforgery was enforced;- a weak password was rejected;
- the owner was created and the page redirected to the login;
/setupthen returned 404;- the new owner signed in with the Owner role, all permissions, the browser time zone and the root organization.
Phase 4b — Rename the server projects (drop Admin) — ✅ done (host name: Api)
Phases 0–4 above describe the tree as it was then (
src/Server/Admin/WebService,Server.Admin.Services, …). From here on, the paths are the post-rename ones. The "Admin" level no longer describes anything. The admin UI moved toClient.Desktop; the business-logic project was never admin-specific; and the host is now the HTTP surface for every client (API, SignalR, health,/setup, and hosting of the two WASM apps).
| Today | Proposed folder | Assembly / root namespace |
|---|---|---|
src/Server/Admin/WebService (DevCoreApp.Admin.WebService) |
src/Server/Api |
DevInstance.DevCoreApp.Server.Api |
src/Server/Admin/Services (DevCoreApp.Admin.Services) |
src/Server/Services |
DevInstance.DevCoreApp.Server.Services |
mocks/Server/Admin/ServicesMocks |
mocks/Server/Services.Mocks |
DevInstance.DevCoreApp.Server.Services.Mocks |
tests/Server/WebService (WebService.Tests) |
tests/Server/Api (Api.Tests.csproj) |
unchanged: DevInstance.DevCoreApp.Server.Tests |
The result is src/Server/{Api, Services, Database, Email, Storage} next to
src/Client/{Client.Desktop, Client.Mobile, Client.Services}.
- Host name:
Api(recommended; says the UI lives elsewhere) orHost(more neutral about the SignalR/health/static-hosting side).WebandGateway/Backendwere rejected as misleading. - Scope:
- namespaces and
usings in both projects, plus mocks and tests; .csprojnames and references, the.slnx,launchSettings.json;- CI's test glob (
**/tests/**/*[Tt]ests.csprojstill matches); - every
CLAUDE.md,CONTRIBUTING.mdand this plan; docs/*paths. Database, Email, Storage, Shared and Client are untouched.
- namespaces and
- Cross-repo impact: sync keys change, for example
Server.Admin.Services.Core.ApiKeys.ApiKeyAdminService→Server.Services.Core.ApiKeys.ApiKeyAdminService. Forks must rename the same way, including theirAppcode under these namespaces, so the rename ships in the same fork migration doc as the WASM move: one disruptive change, not two. - Order: after Phase 4, before Phase 5, so the migration doc and docs describe the final layout.
- Verification (done):
- full build (Debug and ServiceMocks) and all 82 tests pass;
- every
ProjectReferencein the repo resolves, and every relative link in current Markdown does too; - no
Server.AdminorAdmin.*references remain outside the historicaldocs/migration/**and.github/upgrades; - the Rule 2
Appprobe built cleanly in Api, Services, Desktop and Mobile; - the host ran: both clients and runtimes were served,
/setupreturned 404,/Errorrendered,api/usersworked, and the server logged no errors.
- Found while verifying (pre-existing, not the rename):
UserProfileService.GetListAsynccounts and pages profiles, then drops each profile whose Identity user no longer exists. With orphaned profiles,totalCountis too high and pages come back short or empty (top=3→ 0 of 7). The fix belongs in the query (exclude orphans before count/paging), plus finding out why user deletion leaves profiles behind.- ✅ Fixed:
IUserProfilesQuery.WithApplicationUser()(EXISTSonAspNetUsers), applied before count and paging, with tests inCore.Tests/UserAdmin/UserProfilesQueryTests. On the dev DB,top=3now returns 3 + 1 of 4. - Origin: the three orphans were all owner profiles from 2026-02-04 setup attempts. No
current code path deletes a user and keeps its profile (delete removes the profile first;
setup and registration create the user first). The enabler is that
UserProfiles.ApplicationUserIdhas no foreign key toAspNetUsers. Adding one needs a migration in both providers, plus a cleanup of existing orphans. It is left undone because the model documents profiles without accounts as intended.
- ✅ Fixed:
Phase 5 — Docs & fan-out — ✅ done
- ✅
docs/Api.md(wire contract: responses, error shape and numbering, lists and queries, UTC, auth endpoints, binary endpoints, notifications, hosting). It is linked from the root and ApiCLAUDE.md. - ✅ Mobile
CLAUDE.md(base path, shared session, where field features go). - ✅
src/Server/Api/CONTRIBUTING.mdrewritten as an end-to-end walk-through of one feature (API keys) from entity to Desktop page, with a checklist. It replaces the Blazor Server–era guide. - ✅ Fork migration doc
docs/migration/out/2026-09-26-wasm-client-and-api-layer.md: step 0 is the rename, then packages, standalone server fixes, contract, lists, services, controllers, notifications, the client layer, the clients and the cut-over, with per-fork notes for ThreadIQ (local→UTC dates are breaking for the shipped mobile app; the envelope-basedCrmCrudControllerBase) and Tentrie (Core/App restructure first; 89 envelope actions; ~26 product pages). - Follow-ups (not in this migration):
the orphaned-profile paging bug (Phase 4b): fixed (see Phase 4b);the login page should redirect when already signed in: done. Desktop and Mobile login forward a signed-in user to the (safe, never-the-login-page)returnUrl.AuthTokenHandlernow signs out when even the replay with a freshly refreshed token gets a 401, so a dead session can't bounce between the login page and the page that sent it there;the import-validate: done.mappingsJsonTODOPOST api/import-export/import/validatebinds a multipartImportValidateForm(File,EntityType,OrganizationId,Mappings[i].*as indexed form fields); the client service sends exactly that.ImportValidateBindingTestsdrives the real client service against a server action binding the form. Wire change:entityTypeandorganizationIdmoved from the query string to the form, andmappingsJsonis gone;running from Visual Studio: done —DevInstance.DevCoreApp.slnLaunch(Api + Desktop / Mobile, http and https), API-onlyhttp-api/https-apiprofiles, and Mobile's dev server getspathbase=/mobile(it was serving at/while the app asked for/mobile/_framework/…);- clicking through every write flow by hand;
- bUnit page tests;
- the dead
AuthorizationServiceTestsfile; - the stale
.github/workflows/blazor-app-dev_devcoreapp.yml; - committing the local WebServiceToolkit branches (
feature/service-error-contract,fix/query-binder-string).
Target: ThreadIQ, Tentrie, future forks. Content:
0. Project rename (Phase 4b): Server.Admin.Services → Server.Services and
Server.Admin.WebService → Server.Api (or the confirmed name). Apply it first, because every
other step's sync keys use the new names.
- Shared surface added:
ApiControllerBase,ServiceActionErrorcontract,UtcDateTimeJsonConverter;Corecontrollers,ClientHosting,IAccountService/IGridProfileService/ICurrentUserService;- the
Client.DesktopCore/**tree andClient.Services/Core/**.
- Shared surface removed: Blazor Server
Core/UI/**, Identity components, cookie UI sign-in. - Per-fork work (not automatable):
- ThreadIQ:
- Server decorators must stop converting to
UserTimeZone, which is a breaking change for the shipped mobile client. The mobile display code (CrmDisplay.Relative, etc.) must convert UTC→local in the same release. - Fold
CrmCrudControllerBaseintoApiControllerBase. - Resolve the envelope-vs-bare decision (D2).
- Replace bare
[Authorize]with policies. - Its
ThreadIQ.Clientbecomes theClient.Mobileequivalent. Appadmin pages (CRM) must be ported to Desktop.
- Server decorators must stop converting to
- Tentrie: has its own
Tentrie.ClientWASM + TS/SCSS pipeline and a new bUnit test project. It needs an assessment of which is Desktop vs Mobile before applying;AppBlazor Server pages must be ported.
- ThreadIQ:
- Order:
- server groundwork and API first (the UI keeps working);
- then the client;
- then the cut-over;
- with a checklist and verification steps per stage.
| # | Question | Decision |
|---|---|---|
| Q1 | Wire format | Bare T on success, ServiceActionError on error; BlazorToolkit CacheableSource changes to read bare T |
| Q2 | Desktop auth | JWT, same as Mobile |
| Q3 | Setup protection | "No users exist yet" gate only |
| Q4 | Mobile scope | Phase 2 gives Mobile login + profile screens over the shared Client.Services |
| Q5 | Display time zone | Profile TimeZoneId, browser zone as fallback |