Skip to content
in buildSlices 1–2 of 4 done · slice 3 (BFF and deploys) all but PR previews (GitHub sign-in through Nhost live since 2026-10-25) · slice 4 (polish) partly

Atlas Admin Shell: an admin console composed at runtime

A microfrontend admin console: a zoneless Angular 21 shell loads three separately deployed apps (IAM, Feature Flags, Audit Logs) at runtime from a manifest; an edge BFF checks JWTs. No LLM. Live, real sign-in, mock data.

Built for the case where several teams ship parts of one admin console on their own schedules: each domain builds and deploys alone, the shell finds it at runtime without a rebuild, and authorization holds on a server, not in the browser.

stack:
Angular 21 (zoneless, signals) · Native Federation · Nx 23 · Cloudflare Workers · NgRx Signal Store · jose (RS256 JWT) · Nhost Auth (GitHub OAuth) · Tailwind v4 + Style Dictionary · Vitest + Playwright + axe · Storybook 10 · Lighthouse 13
when:
2026
published:
Atlas Admin Shell: architecture The shell Worker on Cloudflare serves the Angular shell's files and federation.manifest.json; on the same Worker, an edge BFF answers /api/* and verifies RS256 JWTs. Three more Workers serve the IAM, Feature Flags and Audit Logs remotes, each from its own origin. In the browser, the zoneless Angular shell reads the manifest, builds its routes from it and loads each remote's entry through an import map. The remotes keep their own Signal Stores and share one AtlasAuth instance and a typed event bus with the shell. Remotes call the BFF only when the manifest sets apiMode to http, and AtlasAuth uses Nhost with GitHub sign-in when the manifest has an auth block, and mock personas otherwise. The live console has an auth block but sets apiMode to mock, so its sign-in is real and its data is mock. Cloudflare Workers · four origins Shell Worker shell assets federation.manifest.json Edge BFF · /api/* on the shell Worker verifies RS256 JWTs 3 remote Workers one origin each remoteEntry.json serves import map if apiMode: http Browser · one page Angular shell zoneless · signals routes from manifest AtlasAuth one shared instance or mock personas IAM · Flags · Audit Signal Store each typed event bus if auth block Nhost Auth GitHub sign-in · live
One Worker serves the Angular shell, its manifest and the edge BFF; three more serve the remotes from their own origins. In the browser the shell loads the remotes at runtime and shares one auth service with them. · scroll sideways to see all of it

Problem & constraints

Atlas Admin Shell is the kind of admin console a large organisation runs across several teams: identity and access, feature flags and an audit log, each owned by a different domain team. The hard part is not the screens. It is letting each domain build, test and deploy on its own while users see one console, without a release train through the shell. The repository states its deliverable as architecture, not features, and its own documents also call it a microfrontend control plane.

Four constraints shaped it. Every federation participant runs one exactly pinned Angular version, so the shell and the three remotes share one framework at runtime. Domain boundaries are enforced by the build, not by convention: a remote cannot import another remote, and only shared libraries cross. The console has to run with no backend and no identity provider, so a fresh checkout, a reviewer and the test suite need no account. And every significant decision is an ADR with a Verification section of dated receipts; when a receipt stops being true, the ADR says so instead of being rewritten.

It uses no LLM.

Architecture

Atlas runs as four Cloudflare Workers on four origins: one for the shell and one per remote (IAM, Feature Flags, Audit Logs). The remotes are cross-origin on purpose, so the import map, the CORS headers and the error boundary are exercised in production the way the design describes them.

At startup the shell fetches federation.manifest.json, uncached, and initialises Native Federation from it: ES modules and an import map, no bundler-specific runtime. Each entry names a remote’s entry URL, route, display name, icon, exposed module and required role, and the shell builds its routes and navigation from those entries. Adding or repointing a domain is an edit to that file and an upload, not a shell rebuild. A canMatch guard checks the role before a remote is downloaded at all. A remote that fails to load sends the user to an error page the shell owns, with a retry that works in the page, instead of a cached fallback the router would never try again.

Every app is zoneless Angular 21 built on signals. Each remote owns its NgRx Signal Stores, and only two things cross a boundary: AtlasAuth, the auth service every remote injects as the same instance, and a typed event bus whose payloads carry a type and a version, pinned by contract tests. Remotes never import each other, and an Nx lint rule enforces it.

On the shell’s own Worker, /api/* goes to an edge BFF and every other path is served as a static file. The BFF verifies JWTs with jose against the identity provider’s JWKS and enforces what a browser cannot: admin-only flag writes, never removing the last admin, no self-demotion, idempotent writes, and an audit trail written on the server from the verified token. Whether the remotes call it is environment data: the manifest’s apiMode is mock or http, and its auth block chooses the identity provider, mock personas or Nhost with GitHub sign-in. Without either, the console runs on mock data with mock sign-in and needs no server at all. The decisions behind it are logged below, each with the alternative it beat.

Claims that did not hold

The repository keeps its corrections in the ADRs instead of rewriting them, and they are among the most useful parts of it.

The Angular version: ADR-0002 pinned Angular 21 LTS because Nx, NgRx and Storybook could not follow Angular 22 within their declared peer ranges. By 2026-10-22 all of them could, and the ADR had gone on stating the old cause for months. The decision stands and its reason changed: Angular 22 has no LTS tag, nothing in it closes a gap this codebase has, and it changes the canMatch signature the error boundary is built on. The migration is deferred on cost and benefit, not blocked.

The manifest validator was meant to be stricter than the shell’s runtime check. When apiMode joined the contract, only the runtime check learned it, so a manifest with a one-letter typo passed the gate and booted a console with zero remotes. A test now asserts over generated mutations that whatever the runtime check rejects, the validator rejects too, and a second test fails when the fixture falls behind the fields the runtime check reads.

The first Lighthouse run measured the federated route almost exactly like the shell (LCP 1,446 vs 1,444 ms), which cannot be true if one of them loads a remote. The signed-in persona had never reached Lighthouse’s own browser context, so the shell redirected to an empty overview and the runner measured that. It now refuses to report unless the run landed on the URL it asked for and actually fetched a remote’s chunks.

Two protections passed every suite while missing. The BFF accepted a token with no expiry claim forever, because the tests never minted one. And nothing in the workspace styled keyboard focus while axe passed on every screen, because axe has no rule that measures a focus indicator. Both are fixed, and each fix was checked by breaking it again and watching the new test fail.

Outcomes

Slices 1 and 2 of the roadmap’s four are done. Slice 3 (BFF and deploys) is done except PR previews; slice 4 (polish) has Lighthouse, the axe pass and the written architecture done, while the demo video and the Angular 22 migration are open. All four Workers were redeployed on 2026-10-25, and the BFF on the live shell refuses an unauthenticated request to /api/flags with a JSON 401.

The live console shows the composition and a real sign-in, not the security at the edge. Since 2026-10-25 its deployed manifest carries the Nhost block, and a GitHub sign-in completed on it, by hand; no automated test drives it, and a token refresh in production has not been observed. apiMode is still mock, so the data is mock and nothing a visitor does reaches the BFF: no real request has been verified at the edge. PR previews need an edge router that mounts every remote under one origin, which does not exist yet, and the demo video is not recorded.

The figures above come from ADR-0012, measured on 2026-09-06 as the median of three desktop runs on the production build. The shell alone scored 0.96 with an LCP of 1,445 ms; the federated /flags route scored 0.94 with 1,629 ms. Composing a remote at runtime costs about 318 ms of FCP and 184 ms of LCP, which is the price of the manifest-driven design, measured instead of assumed. The test server sent no compression and no caching, so these are a floor on localhost, not production numbers.

The checks run locally. GitHub Actions minutes for private repositories ran out on 2026-10-22, so the hosted CI and Deploy workflows are disabled, and all fourteen gates run with one command, pnpm run ci:local: a zoneless guard, manifest and design-token checks, lint, typecheck, unit and contract tests, the builds, wrangler checks, a bundle check, axe over Storybook, end-to-end tests of the federation against production builds, the BFF over HTTP, and Lighthouse. The repository’s recent pull requests record all fourteen passing; that is pasted output from the author’s machine, not a check GitHub ran.

Lessons

A receipt expires. ADR-0002’s reason stayed in the README for months after it stopped being true, in a repository whose first rule is that a claim needs a receipt. Dating every check made the expiry findable; it did not make anyone look. Re-checking receipts is a task, not something you get by having written them.

A gate’s silence is not coverage. The algorithm pin, the expiry check, the interceptor’s origin check and the focus ring all passed every suite while broken or missing, because no test could produce the failing case. The fix was the same each time: break the protection on purpose and watch the new tests, and only those, fail.

Budget for the architecture you chose. Runtime federation costs requests before the first domain paints, and a Lighthouse budget tight enough to look impressive would have been an argument against the design. Measuring the shell and a federated route separately turned that cost into a number.

Losing hosted CI is a discipline problem, not a compute problem. Running the gates locally kept every one of them and runs more than the hosted pipeline did, but nothing stops a merge that nobody checked. The ADR says so instead of pretending otherwise.

Key decisions

  1. 01 Native Federation inside an Nx monorepo

    One Nx monorepo for the shell, the three remotes and the shared libraries, with Native Federation (ES modules and import maps on Angular's esbuild builder) wired by hand, because Nx has no generator for it.

    Nx's own federation path is Module Federation, which for Angular means the legacy webpack builder. The Angular CLI workspace is Native Federation's supported home, but it loses what Nx is there for: the project graph, affected builds, caching and boundaries enforced by tags. A monolith would remove the architecture the project exists to show.

    The friction was bounded and written down: the request for a Native Federation generator in Nx had been open since February 2024, and the init schematic runs with nx g, not ng g. An escape hatch was committed in advance: generate the wiring in a throwaway CLI workspace and port it over (Atlas docs/adr/0001).

    rejected: Nx with webpack or rspack Module Federation · A plain Angular CLI workspace with Native Federation · A modular monolith without federation

  2. 02 Zoneless everywhere, with zone.js kept out of the tree

    The shell and every remote run zoneless with signals; zone.js may not appear in the installed dependencies at all, and a gate fails the pipeline if it does.

    zone.js is in effect one global per page. In a federated page, a single remote that depends on it either pulls it back into every app's runtime or behaves unpredictably next to the zoneless ones; mixing the two is the worst of both. Zoneless everywhere gives one change-detection model across every boundary and smaller bundles.

    The ADR keeps its own correction: it first predicted that Storybook's zone.js peer would be installed silently. The peer is optional, pnpm installs only required peers, and the installed tree has no zone.js; the gate now checks that instead of trusting it (Atlas docs/adr/0003).

    rejected: Letting each remote choose zone.js or zoneless · Keeping zone.js across the whole federation

  3. 03 Remotes from a manifest that is a deploy artifact

    The shell fetches federation.manifest.json before it boots, uncached, and builds its routes and navigation from it: each entry names a remote's URL, route, display name, icon, exposed module and required role. One committed manifest per environment is copied over the built shell at deploy time, so one shell build serves every environment.

    Static federation redeploys the shell for every remote change. A build-time choice makes the environment an input to the shell build: Nx replaces a configuration's assets array instead of extending it, and fileReplacements bakes the JSON into a hashed bundle. A CI template turns product fields into pipeline output nobody reviews. A config service is the strongest end state but puts a second blocking request and a new outage in front of the whole console; it is deferred behind one function.

    First used for real on 2026-10-21, when PR #18 pointed production from placeholder domains at the real Workers with no rebuild. A self-review found that running the copy as a separate step let the deploy rebuild the shell and ship localhost URLs while staying green; the copy now runs inside the deploy target. A validator checks every manifest, and a test asserts that it rejects whatever the shell's runtime guard rejects (Atlas docs/adr/0004, docs/adr/0010, PR #18).

    rejected: Static federation with remote URLs compiled into the shell · Picking the manifest at build time with an assets mapping or fileReplacements · Generating it in CI from a template · A config service or KV store read at runtime

  4. 04 The edge BFF is the authorization boundary

    A Worker on the shell's own origin answers /api/*, while every other path stays static. It verifies JWTs with jose against the identity provider's JWKS (RS256 only, issuer and expiry required; no audience check, because the provider mints no aud claim), derives the role with the same shared function as the browser, and enforces admin-only flag writes, the last admin, no self-demotion and idempotent writes; audit rows are written on the server from the verified token.

    A canMatch guard stops a download, not a direct request, and writing the BFF showed how thin that was: a viewer could toggle a feature flag, and nothing refused it. The same origin means no CORS preflight on every write and no new field in the manifest, and one role function means the browser and the edge cannot disagree about who is an admin.

    The tests run in real workerd with locally generated keys. Two protections turned out untested or missing: the algorithm pin, and the expiry, since a token with no exp claim verified forever until 2026-10-21. Deactivating a user does not revoke their token; that is left to the identity provider and written down. A BFF deploy is a shell deploy (Atlas docs/adr/0011).

    rejected: A separate API Worker on its own hostname · One API per domain · Enforcing roles in the browser and treating the mock data as the product

  5. 05 Auth behind an interface; the manifest picks the provider

    AtlasAuth is an abstract interface that the shell and every remote inject as one shared instance. Mock personas (admin and viewer) are the default; a provider with GitHub sign-in, now Nhost, implements the same interface and is chosen by the manifest's auth block. No block means the mock; a malformed block fails the whole manifest.

    Supabase first would have blocked the first slice on provisioning and left the demo with no fallback when the provider is down; a hand-rolled server is the most work and the least credible. Tokens in events invite leaks and races, so the remotes reach auth only through the shared instance. Putting the choice in the manifest keeps environment data out of the build, as for the API mode.

    The swap was mostly a drop-in, and the ADR records where it was not: the login signature carried the mock's vocabulary, and a token getter had to be added to the shared contract. The interceptor that keeps the token on the shell's origin checked only the path until 2026-09-23. No remote may bundle the identity client, which a build check enforces (Atlas docs/adr/0006, PR #22). The provider moved from Supabase to Nhost in October; the repo records what the move cost and says that it does not record why (Atlas docs/adr/0014). Production served the mock personas until 2026-10-25, when all four Workers were redeployed with the Nhost block and a GitHub sign-in completed on the live console, by hand.

    rejected: Supabase from day one · A hand-rolled JWT server · Passing tokens to remotes through events

  6. 06 Run the gates locally while the repository is private

    tools/ci-local.mjs runs all fourteen gates with one command, pnpm run ci:local: every project rather than only the affected ones, continuing past a failure to report all of them. The hosted CI and Deploy workflows are disabled, and deploys run from the developer's machine.

    Actions minutes are metered on private repositories, and this one used its monthly allowance on 2026-10-22: every run after that failed before a step ran. The gates are the deliverable, so weakening them was out; paying is a recurring cost for a portfolio piece; a self-hosted runner is a second always-on machine to run and secure.

    The ADR states the cost: a local gate runs only when somebody runs it, a pull request carries pasted output instead of a check GitHub ran, and the runs happen on macOS instead of Linux (Atlas docs/adr/0013).

    rejected: Paying for GitHub Actions minutes · Making the repository public (deferred, not rejected) · Dropping the browser tests or Lighthouse to fit the budget · A self-hosted runner

Evaluation results

Key figures, measured locally (Atlas repository)
MetricValueStatus
Lighthouse performance, shell route / federated route0.96 / 0.94measured

/ is the shell alone, signed out; /flags is signed in and has to fetch the manifest, three cross-origin remote entries and a remote's chunks before it paints. Total Blocking Time 0 ms and CLS 0.000 on both.

Largest Contentful Paint, shell route → federated route1,445 ms → 1,629 msmeasured

The test server sends no compression and disables caching, while production serves compressed and cached files, so the timings are a floor measured on localhost, not production numbers.

First Contentful Paint, shell route → federated route388 ms → 706 msmeasured
Cost of composing a remote at runtime, FCP / LCP+318 ms / +184 msmeasured

The price of loading domains from a manifest at runtime, measured instead of assumed. INP is not measured: a lab run cannot produce it, and the project collects no field data.

Measured: Atlas docs/adr/0012, Verification (2026-09-06, median of 3 desktop runs on the production build, served locally) · Atlas docs/adr/0012, Verification (2026-09-06, median of 3 desktop runs) · Atlas docs/adr/0012 (the difference between the two routes above)

Safety & failure modes

Risks

  • A role read from the wrong token claim would make anyone who signs in an admin
  • A role check in the browser stops a download, not a direct request to the API
  • A token held in a service that every remote can inject could be sent to another origin
  • A deactivated admin keeps a valid token until it expires

Mitigations

  • The role is admin only when the provider's allowed-roles claim lists it, and the default role is never read; the live console also runs on mock data, so nothing a visitor does reaches a server, and the edge BFF answers 401 to a request without a token
  • The edge BFF checks every write against a verified token and holds the rules a browser cannot, such as never removing the last admin
  • The interceptor attaches the token only to /api/ requests on the shell's own origin, and tests with foreign origins pin it
  • Left to the identity provider (short token lifetimes, revocation) and written down; the last-admin rule does not count inactive admins as cover