Skip to content

feat(auth): native multi-realm support with per-realm login hosts and an app portal - #29

Merged
swimmesberger merged 18 commits into
mainfrom
wt/watchtower-reverse-proxy-expose-edd698
Aug 10, 2026
Merged

feat(auth): native multi-realm support with per-realm login hosts and an app portal#29
swimmesberger merged 18 commits into
mainfrom
wt/watchtower-reverse-proxy-expose-edd698

Conversation

@swimmesberger

Copy link
Copy Markdown
Owner

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/ask route-existence oracle and a "Your applications" landing page for realm users.

  • Realm entity with a seeded, non-deletable operator realm (id 1, slug operator). User/Group/StackTemplate gain RealmId (Restrict FKs); uniqueness moves to (realm_id, …) so each realm is its own credential space. routes.domain and stack_templates.name deliberately stay global.
  • Realm = cookie jar = auth host. Each realm gets a login host served as a force-unprotected Caddy self-route (invariant: no realm's login host sits behind its own gate). The operator realm's host remains the configured Auth:Host; host→realm resolution fails safe to the operator login. Realm CRUD reconciles Caddy immediately.
  • Realm-consistent access, single-sourced in RouteAccessPolicy: protected routes require user.realm == route.realm for both Authenticated and Restricted; refusals stay indistinguishable from missing grants; proxy.setAccess refuses cross-realm grants at write time.
  • Per-realm token issuer (= the realm's auth host; operator keeps the pre-realm issuer, so existing consumers are untouched) + an always-present realm claim; one shared key pair; TenantDiscovery/UserInfo bind issuer↔subject realm.
  • Management surface is operator-realm-only — enforced centrally (SystemRealmAuthorizer for all RPC handlers, a matching policy for the SSE stream endpoints). IsAdmin only 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.
  • AddRealms migration backfills all existing rows into the operator realm — upgrades are semantics-preserving, and the rollback path is tested.
  • /api/proxy/ask hardening: 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.
  • App portal: GET /api/access/apps (SSO-cookie, any realm, strictly an intersection over AccessibleRouteIdsAsync narrowed 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

  • The migration adds FK columns to existing SQLite tables (full table rebuilds). The generated SQL was audited column-by-column both ways, and there are up- and down-migration tests (AddRealms_CarriesExistingRowsIntoTheOperatorRealm_WithNothingLost, AddRealms_CanBeRolledBack_WithTheOperatorRealmsRowsIntact). Down() needs a PRAGMA foreign_keys toggle because EF hoists rebuilds to the end of the script.
  • IFeatureFlagService went singleton→scoped (the portal flag is per-user); verified under scope validation, which the test factory (Production env) does not exercise.
  • Documented limitations (README): login policy is instance-wide for now; on the published break-glass port the Host header 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.json export diff-clean, frontend tsc + vite build clean.

…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
@swimmesberger
swimmesberger merged commit 14e20fa into main Aug 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant