feat(auth): native multi-realm support with per-realm login hosts and an app portal - #29
Merged
Merged
Conversation
…tion Native multi-realm, part 1 of 4 (docs/central-auth/design.md §13): the data model and the one place a realm is resolved. - Realm entity + [EntityConfiguration]: unique slug, filtered-unique auth_host, one IsSystem row. The system realm's login host is always Auth:Host, never a stored one, so authentication never needs a row to find its own login page. - User/Group/StackTemplate gain RealmId (FK, Restrict), defaulting to the seeded system realm so nothing that predates realms changes behaviour. - users/groups uniqueness moves from the global normalized name to (realm_id, normalized_*). routes.domain and stack_templates.name stay global and now say why in a comment. - AddRealms migration: creates and seeds the operator realm before anything points a foreign key at it, and backfills realm_id = 1 via the AddColumn default so the SQLite table rebuild copies it through. The rebuild's INSERT…SELECT was audited column by column against the generated script (the hazard from commit e4aeebc); the new test migrates a pre-realm database and reads every field of the surviving rows back. - RealmResolver: host → realm (anything unrecognised is the operator realm, so a misconfiguration is a wrong login page and never a lockout), route → realm via its stack's category, and each realm's login host. - IRealmContext: which population the current scope's credential lookups see. WatchtowerUserStore.FindByNameAsync filters on it, which realm-scopes Identity's own duplicate-name check through the same one filter. - AuthBootstrapService counts operator accounts, not all accounts: an instance whose only users live in a customer realm still has nobody who can adminster it.
Native multi-realm, part 2 of 4 (docs/central-auth/design.md §13). - RouteAccessPolicy gains the realm invariant, written once and shared by IsAuthorizedAsync and AccessibleRouteIdsAsync so the per-route and the bulk answer cannot drift: a protected route only ever admits an account of its own realm, whatever its grants say. Public is split out of the Allow branch because a route that asks nobody who they are has no population to compare. A realm mismatch is indistinguishable from a missing grant, which preserves the anti-enumeration property of the login/continue path. - Login resolves the realm from the host it was served on and pins IRealmContext before touching UserManager, so a login page can only authenticate its own population. - Verify challenges to the *route's realm's* login host; a realm with no host yet fails closed with 401 and warns once per realm. - AuthTokenSigner takes a RealmIdentity value (still no database inside): `iss` is the realm's login host for a non-system realm and today's issuer for the operator realm, plus an always-present `realm` claim. Validation is parametric over accepted issuers and reports the one presented. - TenantDiscovery pins the issuer to the calling stack's realm and re-checks the subject's realm; UserInfo, which has no realm in context, accepts every known realm issuer and then checks the resolved account against the one presented — one key pair signs every realm, so the issuer alone proves nothing about the subject. - WatchtowerClaims owns the principal's shape and emits the realm slug; the Admin role is emitted only for a system-realm account (belt to the handlers' braces). ImplicitAdminCurrentUser reports the operator realm so the Auth:Enabled=false path is unchanged. Existing AuthTokenSigner tests were updated mechanically for the new signatures (mint takes the realm, validate takes the issuer set); their assertions are unchanged, and a new test pins that an operator-realm assertion carries exactly the old claims plus `realm`.
…own gate Native multi-realm, part 3 of 4 (docs/central-auth/design.md §13). CaddyManager.ProjectSites generalizes from one self-route to N: the configured Auth:Host plus every realm's AuthHost, loaded in LoadSitesAsync. Each distinct host gets one force-unprotected TLS site block, and an explicit Route row for one of those domains still renders but is force-unprotected whatever its AccessMode says — the invariant is that no realm's login host may sit behind the forward-auth that redirects to it, which would be a closed loop with only the published port as a way out. The projection stays pure and collapses duplicates and blanks rather than trusting the handlers and the unique index to have prevented them: two site blocks for one domain would produce a Caddyfile that does not load.
…alm gate
Native multi-realm, part 4 of 4 (docs/central-auth/design.md §13).
- Realms module (realms.list/create/update/delete), all [RequireRole("Admin")]:
slug is validated as a stable identifier and never editable (it is the
`realm` claim every application receives); authHost must be a bare host name,
unique among realms, and never the configured Watchtower login host — one
host resolving to two populations would make "who administers this instance"
ambiguous. The operator realm is renameable, never given a stored host, and
never deletable. A realm is deletable only while it holds nothing: no
cascades, because deleting a population would otherwise take every
credential and tenant stack with it in one call.
- users/groups/templates gain an optional realmId (default: the operator
realm, so existing clients are unaffected) and expose it on their DTOs;
users.list and groups.list gain an optional realm filter. Group and user
name uniqueness checks are realm-scoped like the indexes behind them.
- IsAdmin is refused outside the operator realm, and the user handlers pin the
realm context to the target account before writing it back — Identity's
duplicate-name check runs through the realm-scoped store, so saving a realm
account with the default context would ask the wrong population whether a
name is taken.
- groups.setMembers and proxy.setAccess refuse cross-realm subjects at write
time: a membership or grant that the access check can never honour reads
like access somebody has.
- templates.update may change a category's realm only while it has no tenants;
moving a populated one would re-point every tenant route at another
population as a side effect of a form save.
- D10: SystemRealmAuthorizer decorates the framework authorizer, so every
handler on every transport additionally requires a system-realm principal.
Central rather than per-handler, because a rule that must be repeated is one
a new handler can be written without. AllowAnonymous and unauthenticated
callers pass through untouched, and the implicit local administrator reports
the operator realm so Auth:Enabled=false is unchanged. The token-authenticated
mgmt/app APIs are out of scope in v1 and say so in MgmtApiService's remarks.
- The five duplicated SeedPrincipal helpers in the test project now delegate to
one TestPrincipal helper: a principal has to state its realm, and five copies
would be five places to forget it.
rpc-schema.json regenerated: the four realms.* methods plus realmId additions.
…rollback Review follow-ups on the native multi-realm work. One commit rather than two: the new tests for the blocking and the minor findings share RealmAuthFlowTests, and splitting that file would buy an intermediate commit that is harder to read rather than easier. BLOCKING - Cross-realm escape via the SSE streams. WatchtowerHttpEndpoints.Protect only did RequireAuthorization(), i.e. *any* authenticated principal, and minimal-API endpoints never reach SystemRealmAuthorizer — so a customer-realm account with a valid __wt_sso on its own login host could stream deploy output and any container's logs. Both streams now require the new SystemRealmPolicy, which decides the question through the same WatchtowerClaims.IsSystemRealm rule as the handler pipeline (new ClaimsPrincipal overload alongside the ICurrentUser one — one rule, one pair of constants). Deliberately not also an Admin-role requirement: a non-administrator operator account could watch these before and still can. Tests cover realm ⇒ 403, anonymous ⇒ 401, plain operator ⇒ 200. - AddRealms.Down() was unexecutable. The reviewer's diagnosis was right and the suggested reorder is not sufficient: EF's SQLite generator *hoists* every rebuilding operation to the end of the migration, so wherever DropTable sits in the method it is emitted while the three tables still carry realm_id with a RESTRICT foreign key — DROP TABLE's implicit delete is then refused. Reordering the C# cannot help, because the hoist happens after Down() has run. The drop now states its own conditions instead: a suppressTransaction Sql operation that turns FK enforcement off for that one statement (PRAGMA foreign_keys is a no-op inside a transaction, which is why the transaction has to be suppressed), placed after the two DELETEs so their cascades still run with enforcement on. Verified against the generated down script; the new test migrates to head, seeds operator-realm rows, migrates back and asserts the columns and table are gone and the rows survived. - Realm CRUD never reconciled Caddy. create/update/delete now call ApplyAsync() after commit, best-effort, exactly like the route CRUD and proxy.setAccess handlers. Without it a new realm's login host is unserved until an unrelated reconcile, and a protected route already on that domain stays gated — the lockout the force-unprotected self-route exists to prevent. Tested through the recording CaddyManager double, including that a refused write asks for no reload. ALSO - UserInfo emitted roles:["Admin"] straight from user.IsAdmin, bypassing the realm gate; now gated on the operator realm like WatchtowerClaims.ForUser. - RealmResolver.IssuersAsync logs a warning on an issuer collision. It can only arise from Auth:Host being pointed at a host a realm already holds — the handlers refuse the other order — and the map still has to build, so the first realm wins and the warning is what makes the symptom traceable. - The missing-login-host warn-once set is keyed by realm id, not slug, so a deleted-and-recreated realm warns again. - ProjectSites' realmAuthHosts is required; the pre-existing call sites pass an empty list. Forgetting the realm hosts un-serves every realm login page and re-gates routes on those domains, which should be a compile error. - RealmResolver.AuthHostsAsync excludes the system realm, matching ResolveByHostAsync. - Removed the UTF-8 BOMs a scripted edit added — all seven affected files, not only the two flagged; none carried one before.
… proxy GET /api/proxy/ask returns 200/403 by whether a domain exists in the route table, and its comment claimed the endpoint was reachable only on the internal control network. It is not: the force-unprotected self-routes (the configured Auth:Host plus every realm's AuthHost) proxy all paths to this app, so anyone who can reach a login page could probe which domains Watchtower manages. Caddy's on-demand-TLS module calls the ask URL directly and stamps no X-Forwarded-* headers; anything relayed by a reverse_proxy site carries them. A request bearing any forwarding marker now gets a bare 404 — identical for known and unknown domains, and identical to a nonexistent path — while the direct call keeps today's 200/403/400 semantics. Spoofing a marker only makes the answer less informative, so there is no bypass.
RealmResolver.IssuersAsync built its issuer map by iterating ListAsync() (slug-ordered) with TryAdd, so an issuer collision was decided by whichever slug sorted first. With Auth:Host pointed at a host a realm already holds — the one way a collision can arise, since realms.create refuses the reverse order — a customer realm slugged before "operator" would take the operator issuer, and the population that administers the instance would be the one whose assertions stopped resolving. Order the iteration system-realm-first so the operator realm is structurally unable to lose; the warning still names the loser. The collision test becomes a theory over a slug on each side of "operator".
SetAccess already refuses a grant naming a subject from another realm, but the read side said nothing about which realm that is — so a grant editor could only find out by submitting something and being told no. getAccess now resolves the route's realm the same way SetAccess does (the stack's category, or the operator realm for a standalone stack) and returns it, which lets the Access dialog fetch its user and group candidates realm-scoped and never compose a grant the server would reject.
Adds the Realms admin screen and makes the screens that name a population realm-aware, now that realms.* and realmId are on the wire. - Realms screen (/realms, Admin-gated like Users and Groups): list with the system badge, login host and what each realm holds; create with a slug derived from the name until it is edited; edit with the slug read-only and the auth-host input disabled for the operator realm, whose host is the configured Auth:Host; delete disabled with the reason shown when the realm is built in or still holds anything — the DTO's three counts are exactly that guard, and the server still decides. - Users and Groups gain a realm column, an "All realms" filter and a realm on create. The Admin toggle is absent outside the operator realm because users.create/update refuse the pair. The group members dialog offers only the group's own realm. - The route Access dialog scopes its user and group candidates to the realm proxy.getAccess now reports, so a cross-realm grant cannot be composed. - Templates: a realm select on create, and the realm shown on the detail page. The shared roster lives in useRealms() so one ['realms'] query backs every screen; it takes an `enabled` flag because realms.list is Admin-gated and the Templates routes are gated on the module only.
Review follow-ups on the realms UI. No behaviour change beyond the two narrowings noted below. - The realm column now follows the same "more than one population" rule the filter and the create-select beside it already followed, on Users and Groups alike (table column and mobile card). A stock single-realm install showed a column whose every cell said the same word. - AccessDialog scopes its useRealms() to the dialog being open, like the two rosters below it, so realms.list no longer fires on every Routes page mount. - The Access form's copy uses nameOrNull and drops the realm's name from the sentence while the roster has not answered, rather than rendering the "Realm 1" placeholder at a cold open. - The realm edit form sends `name` only when it changed: the update is partial by design, and a save that only moves the login host should say that and nothing else. - api.ts: the realms.update comment described the opposite of what the code does (the code was right); CreateRealmRequest moved to its alphabetical slot. - use-realms.ts: dropped `get`, `isLoading` and `isError` — no callers.
A host-only realms.update wrote renamedFrom=<the same name> into the trail; the detail now names only the fields that differ.
§13 was written as a direction with seams to keep open and was built the same day; it now describes what exists. - §13 rewritten as eight subsections: the Realm entity and the seeded operator realm, the realm foreign keys and what stayed global and why, the single fail-safe resolver, realm = cookie jar = auth host with the N force-unprotected self-routes, the per-realm issuer over one key pair, the access invariant single-sourced in RouteAccessPolicy, the operator-realm-only management surface, the JSON-RPC/UI surface, and §13.8 for what is genuinely still future work (per-realm policy and federation, realm-scoped management principals, protocol-level OIDC, per-category vanity hosts). - §3 gains Realm plus the three RealmId columns and a paragraph on the Restrict foreign keys, the realm-scoped uniqueness, and why routes.domain and stack_templates.name stay global. - §12.3 step 2: the per-category auth host landed realm-shaped rather than template-shaped — a category gets a realm, not a host of its own. - Swept the single-population assertions that are now false: the status line, §2.2's cookie jar, §2.6's bootstrap count, §2.7/§2.8's framing, §5's challenge target, §6's one self-route, §7's module list, §11's auth-host bootstrap risk. README gains a Realms section: what a realm is, creating one (name, the immutable slug, login host + DNS), per-realm login and SSO, the operator realm's host staying WATCHTOWER__AUTH__HOST, management access being operator-realm-only, and the empty-realm delete rule. Two limitations are stated plainly — the published port takes a client-supplied Host, so direct port access can aim lockout attempts at any realm (no credential bypass), and moving or clearing a realm's login host orphans its sessions — plus the note that /api/proxy/ask now answers 404 through the proxy, since every login host proxies all paths to Watchtower.
A signed-in account outside the operator realm can reach its own apps but
is refused every management call, so the SPA had nothing to show it. Two
additions give it something.
- GET /api/access/apps, beside /api/access/userinfo on the any-realm
surface: the __wt_sso cookie in, the routes the caller may enter out, as
{ domain, name } sorted by name then domain. Authorization is
RouteAccessPolicy.AccessibleRouteIdsAsync unchanged — the same reading
verify uses, realm invariant included — so there is no second notion of
accessibility to drift with, and every entry names a route the caller
could already have navigated to. Validated with ValidateAnyAsync for the
same reason UserInfo is: a list is a read, and must not extend the life
of the session doing the listing. 401 with no session, 404 with
Auth:Enabled off, like the rest of the file. Deliberately not an RPC
handler and no change to SystemRealmAuthorizer: /rpc stays the operator
population's.
- An apps-portal client flag on the Proxy module ([ClientFeatures],
ADR-0030), true for a signed-in non-operator account, so the shell can
read the caller's realm off the session snapshot it already fetches
rather than deriving it. Stated in the affirmative because that is the
polarity that degrades safely: an unanswered flag is false, which is
today's management UI exactly. Declared on Proxy because the portal is
that module's routes seen from the visitor's side, so an instance with
no reverse proxy exposes no portal.
MetricsFeatureFlagService becomes WatchtowerFeatureFlagService and turns
scoped: the session bootstrap injects a single IFeatureFlagService, so a
second registration would replace rather than compose, and the new flag
reads ICurrentUser. metrics-history is unchanged.
rpc-schema.json carries the one line of intended drift (the Proxy module's
features list), which is what puts apps-portal into the generated
session-client vocabulary.
Signing in on a realm's login host landed the visitor in the management
shell, where every screen's RPC answered Forbidden — a dead app with a
sidebar. They now get a portal instead.
- platform/AppsPage.tsx: the apps the account may open as cards, each a
plain <a href="https://{domain}/"> because opening one is a document
load onto another origin, where the verify redirect and the silent-SSO
hand-over do the signing in. Loading skeletons, a retry banner, and a
"no applications yet" empty state; the sign-out button carries over
under the same rule as the shell's (nothing to end for an implicit
local user).
- lib/apps.ts: the plain fetch for GET /api/access/apps, in the shape of
lib/auth.ts rather than the RPC client — /rpc is the surface this page's
audience is refused. A 401 bounces to the login page once, the same way
the RPC transport handles its own expiry.
- app-shell.tsx branches on the session snapshot's apps-portal flag and
renders the portal in place of the whole shell, sidebar included, so an
admin path typed in by hand lands there too. The login page still wins,
and the flag is false for the operator realm, for an unauthenticated
boot and with Auth:Enabled off — every one of which keeps today's UI
byte for byte. Post-login redirect to / already lands here.
The page is platform-owned and not a route: module gates key off capability
modules and a realm account has none.
docs/central-auth/README.md gains a paragraph under Realms saying what a
realm user sees, next to the note that management is operator-only.
Review follow-ups on GET /api/access/apps. The first is a real disclosure the endpoint's own remarks claimed it did not have. - The realm intersection. AccessibleRouteIdsAsync deliberately admits a Public route to everybody — no identity is consulted for one, so no population is either — which is right for "may this request pass" and wrong for "what shall I name to you". Any realm's account could read off every public domain the instance proxies, the same enumeration /api/proxy/ask answers 404 to prevent on these very hosts. The result is now intersected with RouteRealmIdsAsync — the seam the policy itself uses, and an intersection rather than a union, so it can only remove entries the policy allowed and D1's single reading of accessibility stands. - Alias domains collapse to the stack's primary, so three names for one app are one card. With a fallback: IsPrimary is a property of the stack's routes and not of the caller's, so an account granted an alias and not the canonical domain keeps seeing what it can reach rather than losing the app. - The link carries the route's own scheme. AppLinkDto gains `url`, built from Route.TlsEnabled; a plain-HTTP route linked as https is a connection failure the browser cannot recover from. - The read is a narrow projection instead of Include(r => r.Stack): a full Stack row carries WebhookToken and AppApiToken, and nothing every realm account can trigger should pull those into memory. The unreachable stack-name fallback goes with it. - Cache-Control: no-store on the 200 — the body is per-account. - ProxyModule states the cost of its own gating: with the module disabled a realm user falls back to the dead management shell. Safe for the operator, not graceful for them, and the reason for the flag's polarity. ValidateAnyAsync becomes ValidateAsync, and the "does not slide the session" claim is withdrawn. Writing the test asked for disproved it: this endpoint is served on the auth host, so UseAuthentication has already resolved the same __wt_sso cookie through the renewing ValidateAsync before any endpoint runs. A non-renewing read here could not have stopped that — it only made the comment describe a property the pipeline does not have. ValidateAsync is also kind-correct (an __wt_access token in the SSO cookie is now refused) and is what /api/auth/continue does. Two tests pin the corrected behaviour instead of the imagined one. UserInfo's ValidateAnyAsync is untouched and still right: it reads the per-app cookie, which no middleware touches. The sort test now uses stack names that order the opposite way from their domains, so it distinguishes "sorted by name" from "sorted by domain".
…shell Review follow-ups on the applications portal. - The card's href is the backend's `url` rather than an https:// built here, so a plain-HTTP route is linked as one. AppLink gains the field. - ThemeToggle moves out of app-shell.tsx into components/ui/theme-toggle and the portal header uses it beside sign-out. The portal replaces the shell rather than living inside it, so reusing the control was the alternative to a second implementation of it. - AppsPage is React.lazy behind a Suspense boundary, the same code-split every route component gets: an operator never renders it and should not carry it in the main chunk (it lands in its own ~3.8 kB asset). No fallback — the page's own skeletons are the loading state, and a spinner ahead of them would render two waits as three. - The skeleton cards are <li> children of the <ul> they sit in.
Two refinements from the second review. - The de-duplication key becomes (stack, service), because that pair is what an entry point is. Aliases are same-service by definition — several names for one app — and still collapse to the canonical domain; two services of one stack are two ways in (a UI on app.x.com, its API on api.x.com) and grouping by stack alone silently dropped one of them, which is worse than the duplication it was avoiding since the caller may be the only person granted the one that vanished. The remarks and the README now use "alias" the way the entity does, and a test pins all three cases at once: alias collapses, second service survives. - AccessibleRouteIdsAsync documents its callee-side contract: only Id and AccessMode are read, so a caller may pass detached stand-ins carrying just those. That turns the apps endpoint's caller-side assumption into something the next editor of the policy sees, in the same spirit as the file's existing notes on RealmAdmits and GrantAdmits — widening what the method reads would otherwise hand such a caller a default in the field rather than a compile error. The stand-in stops passing a real Domain to match: it is a placeholder the `required` modifier demands, not a value anyone consults, and setting it from the row implied otherwise. AddAliasRouteAsync becomes AddStackRouteAsync with an optional service, since "another domain on this stack" is now two different things.
…-proxy-expose-edd698 # Conflicts: # src/Watchtower.Application/Modules/Metrics/MetricsModule.cs # src/Watchtower.Application/Persistence/Migrations/WatchtowerDbContextModelSnapshot.cs # src/Watchtower.Application/Services/MetricsFeatureFlagService.cs # src/Watchtower.Application/WatchtowerServiceCollectionExtensions.cs # src/watchtower-web/src/lib/api.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Implements the native multi-realm direction from
docs/central-auth/design.md§13 (Keycloak-realm-like semantics), plus two follow-ups that fell out of review: closing the/api/proxy/askroute-existence oracle and a "Your applications" landing page for realm users.Realmentity with a seeded, non-deletable operator realm (id 1, slugoperator).User/Group/StackTemplategainRealmId(Restrict FKs); uniqueness moves to(realm_id, …)so each realm is its own credential space.routes.domainandstack_templates.namedeliberately stay global.Auth:Host; host→realm resolution fails safe to the operator login. Realm CRUD reconciles Caddy immediately.RouteAccessPolicy: protected routes requireuser.realm == route.realmfor bothAuthenticatedandRestricted; refusals stay indistinguishable from missing grants;proxy.setAccessrefuses cross-realm grants at write time.realmclaim; one shared key pair; TenantDiscovery/UserInfo bind issuer↔subject realm.SystemRealmAuthorizerfor all RPC handlers, a matching policy for the SSE stream endpoints).IsAdminonly exists in the operator realm.realms.*JSON-RPC module + admin UI (Realms screen; realm-aware Users/Groups/Access dialog/Templates). Single-realm installs look exactly like pre-realm Watchtower.AddRealmsmigration backfills all existing rows into the operator realm — upgrades are semantics-preserving, and the rollback path is tested./api/proxy/askhardening: answers a bare 404 through the proxy (was a route-existence oracle on every login host); Caddy's direct on-demand-TLS call keeps working.GET /api/access/apps(SSO-cookie, any realm, strictly an intersection overAccessibleRouteIdsAsyncnarrowed to the caller's realm) feeds a landing page realm users see instead of the dead admin shell — one card per entry point, launched via the existing silent-SSO dance.Why
Per §13: Watchtower is the fully-integrated identity home; external IdPs are per-realm federation for customers who already run one, not the exit ramp. Realms give products isolated user populations and hand the future management API its natural tenancy boundary. This also exposes Watchtower's own UI through its own proxy under its own IdP, in a realm independent of stack realms.
Reviewer notes
AddRealms_CarriesExistingRowsIntoTheOperatorRealm_WithNothingLost,AddRealms_CanBeRolledBack_WithTheOperatorRealmsRowsIntact).Down()needs aPRAGMA foreign_keystoggle because EF hoists rebuilds to the end of the script.IFeatureFlagServicewent singleton→scoped (the portal flag is per-user); verified under scope validation, which the test factory (Production env) does not exercise.Hostheader selects the login realm (lockout/timing surface, not a credential bypass); moving a realm's auth host orphans its sessions.docs/central-auth/design.md§13 is rewritten as shipped and is the best reading companion for this diff; §13.8 lists what deliberately does not exist yet (per-realm policies/MFA, federation config, realm-scoped management principals, protocol-level OIDC).Verification: 601 tests green (429 Application + 172 Api), zero build warnings,
rpc-schema.jsonexport diff-clean, frontendtsc+ vite build clean.