Backstage
The CBA blueprint tests Backstage as four weighted domains you can revise into a checklist. Out here, once mission-portal is actually live, it's one thing: a TypeScript monorepo your platform team forked, built, and now personally owns for as long as anyone still uses it. This page is the operational companion to that blueprint — not what the exam asks, but what actually sits in your Git organization. The two-package split between packages/app and packages/backend, and the newer backend system that assembles a whole running service out of installable modules rather than hand-wired code. The three files you'll write again and again: catalog-info.yaml describing one service, app-config.yaml configuring the portal itself, and template.yaml turning a golden path into a button anyone can press. The three ways an entity ever lands in the catalog, and which one still works past thirty services. The whole day-to-day toolchain, which is just yarn and docker. And the failure modes — a permission framework nobody turned on, a scaffolder credential with more reach than anyone remembers granting it — that show up months after launch, not during setup.
You know how a big secondhand shop has one till at the front, and one back room where the manager counts the money and phones the suppliers? Backstage is built exactly like that shop, on purpose. The front counter is packages/app — customers only ever see it, and it never gets to touch the cash box. The back room is packages/backend — it's the only room allowed to hold the till key, phone the suppliers, and write in the big ledger of everything the shop has ever sold. New stock doesn't appear on the ledger by magic, either: someone walks it up to the counter and hands it in themselves, the manager writes it in by hand from a list, or — the way a real busy shop actually works — a runner walks the aisles on a schedule, adds anything new, and crosses out anything that's gone. And there's one special button by the till marked "Open a new branch": press it, answer four questions, and a whole new shop — front counter, back room, first ledger entry and all — gets built for you, the exact same correct way, every single time.
Architecture: the two-package monorepo, and the backend you assemble from modules
☺ Like you're 10: One box is the shop window anyone can look through. The other box is the back room, and only it is ever allowed to hold a key.
npx @backstage/create-app@latest doesn't install anything — it scaffolds a Yarn workspace and hands you the keys to it. Inside sit exactly two packages that matter for this page. packages/app is a React single-page application that renders every tab, card, and form a developer clicks through; it ships to the browser, which means anything it contains is public by definition. packages/backend is a Node.js service that owns the one thing the frontend structurally never can — a real connection to a database, to GitHub, and to whatever clusters and cost systems you've wired in. The frontend talks to the backend over plain HTTP, hitting a common router at /api/<plugin> for whichever plugin's data it needs; a database password, a GitHub App's private key, a service-account token, all belong in the backend and nowhere else.
How that backend is actually assembled changed meaningfully in recent Backstage releases, and both shapes still turn up in the wild, so it's worth knowing both. The legacy backend hand-wired each plugin's router into an Express app yourself, in code only the person who originally wrote it fully understood. The new backend system replaces that with one createBackend() instance and a flat list of backend.add(import('@backstage/plugin-...')) calls — every capability the portal has, from the catalog to the scaffolder to TechDocs, is a self-contained module registered the same way, in whatever order you like, resolving its own dependencies through Backstage's own dependency-injection system rather than you threading them through by hand. A current create-app produces the new system by default; if you inherit an older portal, migrating it module by module — never in one sitting — is the sanctioned path, and the official upgrade helper linked at the foot of this page diffs your version against the tip of main to show exactly what moved.
Notice the shape: one process, many independently-authored modules, each doing one narrow job and declaring what it needs rather than reaching out and grabbing it. It's the same idea this course keeps returning to at a different scale — Kyverno's four controllers, each with exactly one job; Argo CD's application-controller/repo-server split. Backstage draws that same line inside a single Node process instead of across separate Deployments, but the reason is identical: a narrowly-scoped module is easier to reason about, test, and eventually replace than one file that quietly does everything.
The resources you actually write
☺ Like you're 10: Three files carry almost the whole job: one card per shop item, one settings sheet for the shop itself, and — a little further down this page — one form that builds a whole new shop for you.
Backstage introduces no Kubernetes CRDs of its own. Everything it reads is plain YAML sitting in Git, which the backend parses on a schedule or on request. The two files below carry the Infrastructure and Catalog domains between them; the third, template.yaml, gets its own section further down, because the scaffolder deserves the room.
catalog-info.yaml — the card for one service, and everything that ships beside it
This file lives at the root of the repository it describes, and that placement is the whole trick: the description ships in the same pull request as the code it describes, so it can't drift the way a wiki page always eventually does. Multiple entities can live in one file, separated by ---, which is how a service, the API it exposes, and the database it depends on land together as one unit instead of three separately-maintained pages.
# catalog-info.yaml — lives at the ROOT of mission-log's own repository
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: mission-log
title: Mission Log
description: Records every reconciliation event across the fleet.
tags: [go, telemetry, tier-1]
annotations:
backstage.io/techdocs-ref: dir:. # Docs tab builds from ./mkdocs.yml
backstage.io/kubernetes-id: mission-log # Kubernetes tab matches this label
argocd/app-name: mission-log-prod # Argo CD tab shows sync + health
github.com/project-slug: kubestronaut/mission-log
spec:
type: service
lifecycle: production
owner: group:default/platform-team # -> derives ownedBy / ownerOf
system: telemetry # -> derives partOf / hasPart
providesApis: [mission-log-api]
dependsOn:
- resource:default/mission-log-db
---
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: mission-log-api
spec:
type: openapi
lifecycle: production
owner: group:default/platform-team
system: telemetry
definition:
$text: ./openapi.yaml # rendered as browsable API docs
---
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: mission-log-db
description: Postgres 16, provisioned alongside this service.
spec:
type: database
owner: group:default/platform-team
system: telemetryspec.owner and spec.system point at other entities by name, and Backstage never validates that the target actually exists at write time — only at read time, silently. Reference a Group that hasn't been ingested yet and you get a dangling owner reference: the ownedBy edge is recorded, but it resolves to nothing on the page. Ingest your organization — Groups and Users, from an identity provider or a checked-in org.yaml — before you ingest components, and prefer the fully-qualified group:default/platform-team over a bare platform-team. The same logic applies to system: — reference telemetry before it exists and the Component still ingests fine, it just floats with no parent until the System entity catches up.
app-config.yaml — the portal's own settings, layered and substituted
One YAML file configures the whole application: catalog rules and locations, every integration, TechDocs, and anything a plugin needs to know at startup. app-config.local.yaml overlays it for a laptop, app-config.production.yaml overlays it again for a real deploy, and ${ENV_VAR} substitution runs throughout — so the file that's readable in a pull request never has to contain an actual secret.
app:
baseUrl: https://portal.kubestronaut.example
backend:
baseUrl: https://portal.kubestronaut.example
listen: { port: 7007 }
database:
client: pg # SQLite is DEV ONLY — no persistence
connection:
host: ${POSTGRES_HOST}
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
integrations:
github:
- host: github.com
apps:
- $include: github-app-credentials.yaml # a GitHub App, never a personal token
catalog:
rules:
- allow: [Component, API, Resource, System, Domain, Group, User, Location, Template]
locations: # org data FIRST, so owners resolve
- type: url
target: https://github.com/kubestronaut/platform-config/blob/main/catalog/org.yaml
providers: # the route that scales — see below
github:
missionOrg:
organization: kubestronaut
catalogPath: /catalog-info.yaml
filters: { branch: main, repository: '.*' }
schedule:
frequency: { minutes: 30 }
timeout: { minutes: 3 }
techdocs:
builder: 'external' # built in CI, never on request — see Gotchas
generator: { runIn: 'docker' }
publisher:
type: 'awsS3'
awsS3: { bucketName: kubestronaut-techdocs }
kubernetes: # powers the Kubernetes tab on each entity
serviceLocatorMethod: { type: 'multiTenant' }
clusterLocatorMethods:
- type: 'config'
clusters:
- name: prod-eu
url: https://k8s-prod-eu.kubestronaut.example
authProvider: 'serviceAccount'
serviceAccountToken: ${K8S_PROD_EU_TOKEN} # read-only, please☺ Like you're 10: This one file says where the shop lives, which filing cabinet it uses, which branch to scan for new stock, and which back rooms elsewhere it's allowed to peek into.
The scaffolder: turning a golden path into a button
☺ Like you're 10: One form, filled in once, that builds a whole new shop for you — front counter, back room, and its first ledger entry, all at once, correctly, every time.
A Software Template has exactly two halves, and the curriculum's own language for them is precise. spec.parameters is JSON Schema, rendered as a form — enum becomes a dropdown, pattern becomes live validation, and a handful of Backstage-specific ui:field widgets turn a plain text box into something wired straight to the catalog: OwnerPicker picks a real Group, RepoUrlPicker picks a real destination host and org, EntityPicker picks any other entity by kind. spec.steps is the ordered list of actions that actually run — server-side, in the backend, once the form is submitted — and every later step can read an earlier one's output through ${{ steps.<id>.output.<field> }}.
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: golden-path-service
title: New Mission Service (Golden Path)
description: Repo + CI + namespace + database, wired to the paved road.
tags: [recommended, go]
spec:
owner: group:default/platform-team
type: service
parameters: # -- the form a crew member fills in --
- title: Service details
required: [name, owner]
properties:
name:
title: Service name
type: string
pattern: '^[a-z][a-z0-9-]{2,29}$'
owner:
title: Owning team
type: string
ui:field: OwnerPicker # picks a Group from the catalog
ui:options: { catalogFilter: { kind: Group } }
size:
title: Database size
type: string
default: small
enum: [small, medium, large] # renders as a dropdown
- title: Repository
required: [repoUrl]
properties:
repoUrl:
title: Location
type: string
ui:field: RepoUrlPicker
ui:options: { allowedHosts: [github.com], allowedOwners: [kubestronaut] }
steps: # -- what runs on submit, in order --
- id: fetch
name: Render the skeleton
action: fetch:template
input:
url: ./skeleton # the directory next to this file
values: # substituted into ${{ values.* }}
name: ${{ parameters.name }}
owner: ${{ parameters.owner }}
size: ${{ parameters.size }}
- id: publish
name: Create the repository
action: publish:github
input:
repoUrl: ${{ parameters.repoUrl }}
defaultBranch: main
repoVisibility: internal
access: kubestronaut/platform-team # admin collaborator: an org/team SLUG
- id: register
name: Register in the catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output: # links shown on the success page
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Open in catalog
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}Beside that file sits a skeleton directory — an ordinary project tree with ${{ values.* }} placeholders sprinkled through it: skeleton/catalog-info.yaml already reads name: ${{ values.name }}, skeleton/.github/workflows/ci.yaml holds your standard pipeline, skeleton/db-claim.yaml a database claim sized by size. fetch:template renders every file and drops the result straight into the new repository, so the service is born already catalogued, already built by CI, already on the paved road — and nobody typed any of that in by hand.
Omit Template from catalog.rules.allow and every template you've published simply refuses to appear on the Create page — no error anywhere, just an empty list, because the catalog never ingested the Template kind at all. And because publish:github genuinely creates repositories, grants team access, and opens pull requests, the credential behind it is a real production capability: use a scoped GitHub App rather than a personal access token tied to one engineer, keep the templates repository restricted to platform-team merges, and inject the credential at runtime rather than committing it beside the templates it drives.
Copy golden-path-service above into a repo of your own, next to a skeleton/ folder containing nothing but a README.md and a catalog-info.yaml with name: ${{ values.name }}. Add its URL under catalog.locations with rules: [ { allow: [Template] } ], restart yarn dev, and find it on the Create page. Fill in the form, submit, and watch three things happen in order: a real repository appears under your account, it contains a rendered catalog-info.yaml with your chosen name already substituted in, and — because the last step ran — a brand-new Component shows up in the catalog with no manual registration at all. Now delete the register step and run it again: the repo still appears, but nothing shows up in the catalog, which is the fastest way to feel why catalog:register is its own separate step rather than something fetch:template does for free.
Catalog ingestion: three routes onto the graph
☺ Like you're 10: Stock gets onto the ledger three ways: someone hands it to the till themselves, the manager writes it in from a list, or a runner walks the aisles on a schedule and keeps the ledger honest without being asked.
Three mechanisms exist, and the curriculum names two of them as their own competencies because they behave differently enough in production to trip people up, not just on paper. Manual registration is a human pasting a URL into "Register existing component," which creates exactly one Location — fine for one repo, a genuine liability at scale, because nothing removes that Location when the repo it points to is deleted or renamed. Static locations are URLs listed directly under catalog.locations in app-config.yaml — the right shape for things that rarely change and matter a lot, like the org.yaml carrying your Groups and Users, or the template locations from the section above. Entity providers are the only mechanism built to scale past a handful of services: catalog.providers.github — with equivalents for GitLab, Bitbucket, Azure DevOps, and org-data providers like LDAP or Microsoft Graph — crawls a whole organization on a schedule, ingesting every repository whose default branch carries a catalog-info.yaml, and, critically, removing the entity again the moment that file disappears. A Location, by contrast, just goes stale and sits there.
You never write the reverse of a relationship yourself — the catalog derives it. Set spec.owner: platform-team on a Component and Backstage creates the ownedBy edge and the reciprocal ownerOf edge back on the Group, automatically; the same pairing covers partOf/hasPart, providesApi/apiProvidedBy, and dependsOn/dependencyOf. The Backstage Portal Model covers why that graph shape matters more than it looks like it should; this page only needs you to know it's derived, not hand-maintained, before the troubleshooting below makes sense.
Troubleshooting entity ingestion is its own named competency for a reason — three failure modes account for nearly everything that goes wrong in practice. A malformed or unreachable catalog-info.yaml surfaces as a processing error visible on the entity's own page, and the entity simply stops refreshing until it's fixed. An orphan is an entity whose parent Location vanished — Backstage tags it with backstage.io/orphan, so you can find every one of them in a single query. And a stale entity lingers when the repository itself is gone but the Location that registered it never was — the fix there is deleting the Location, not the entity, because deleting the entity directly just gets it re-created on the next scan.
# What does the catalog actually think exists, right now? curl -s -H "Authorization: Bearer $TOKEN" \ "$BASE/api/catalog/entities/by-query?filter=kind=component,spec.lifecycle=production" \ | jq -r '.items[] | "\(.metadata.name)\t\(.spec.owner)"' # One entity in full — check managed-by-location and any orphan annotation first curl -s -H "Authorization: Bearer $TOKEN" \ "$BASE/api/catalog/entities/by-name/component/default/mission-log" \ | jq '.metadata.annotations' # Every orphan in one query — parents that vanished, entities left behind curl -s -H "Authorization: Bearer $TOKEN" \ "$BASE/api/catalog/entities/by-query?filter=metadata.annotations.backstage.io/orphan=true" \ | jq -r '.items[].metadata.name' # Ghost service still on the page? List locations, then delete the one that created it curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/catalog/locations" | jq . curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$BASE/api/catalog/locations/<id>"
Day-to-day commands
☺ Like you're 10: No special control panel — the same handful of commands any website's own developers already type all day.
There's no backstage CLI that talks to a running server the way argocd or kyverno do — this is a Node application, so the toolchain is Yarn, npx, and its own HTTP API.
# Scaffold a brand-new portal — a monorepo you now own, not an install npx @backstage/create-app@latest --path mission-portal cd mission-portal yarn install # resolves the whole Yarn workspace yarn dev # app on :3000, backend on :7007, both hot-reloading # Run one half at a time — useful when only the backend is misbehaving yarn workspace app start yarn workspace backend start # Add a plugin — most ship as a pair, frontend AND its backend counterpart yarn workspace app add @backstage/plugin-kubernetes yarn workspace backend add @backstage/plugin-kubernetes-backend # Scaffold your OWN plugin inside this monorepo yarn new # Type-check, lint, and test the whole workspace before you trust it yarn tsc && yarn lint:all && yarn test:all # Production build, then a container image from the BACKEND's own Dockerfile yarn build:all yarn build:backend --config ../../app-config.yaml docker image build . -f packages/backend/Dockerfile --tag kubestronaut/mission-portal:2026.08.1 # Keep every @backstage/* package on one release line npx @backstage/cli versions:bump # Preview TechDocs locally, then build + publish the way CI does (builder: 'external') npx @techdocs/cli serve npx @techdocs/cli generate --source-dir . --output-dir ./site npx @techdocs/cli publish --publisher-type awsS3 --storage-name kubestronaut-techdocs \ --entity default/component/mission-log
Two details there earn marks on their own if this shows up on the CBA paper. The image is built from packages/backend/Dockerfile, not two separate Dockerfiles, because in a production build the compiled frontend is served by the backend — one image, one process. And versions:bump is the whole upgrade story: Backstage releases often, its packages must move together, and a portal left untouched for a year is a genuinely painful thing to bring forward again.
Gotchas and failure modes
☺ Like you're 10: Most surprises come from a default nobody changed, a credential doing more than anyone remembers granting it, or forgetting that the shop only ever updates its own ledger — never what's actually running.
You adopted an application — upgrades are now your job, forever
This is the gotcha that sinks adoptions quietly, months after launch rather than during it. Backstage ships as source you fork and own: a TypeScript monorepo with a frontend, a backend, a Dockerfile, and your own plugin wiring. You own its build pipeline, its Postgres, its SSO, its uptime, its dependency CVEs, and — the big one — its upgrades. The project has moved through significant architectural shifts, the new backend system among them, so a portal left alone for a year is genuinely painful to bring forward. Name an owner before launch, run versions:bump on a cadence, and upgrade in small hops using the official upgrade helper rather than one heroic leap eighteen months late.
Auth and permissions are opt-in, and the generated defaults are not production
Two defaults bite hard, and neither is loud about it. The generated app uses a guest sign-in provider and an in-memory SQLite database — fine for yarn dev, catastrophic in production, where you need real SSO and Postgres. And Backstage's permission framework is opt-in: until you write a policy, any authenticated user who reaches the portal can execute any template — which, given the scaffolder's real credentials, means anyone who can log in can create repositories and grant team access in your org. Turn on SSO, enable the permission backend, and gate expensive templates before you announce the portal company-wide. That's a governance requirement, not a nice-to-have you get to later.
Providers at scale can exhaust your own rate limit
An entity provider crawling thousands of repositories at a tight schedule.frequency will quietly exhaust your source-control API's rate limit long before anyone thinks to check it — widen the interval, and prefer a GitHub App's higher quota over a personal access token for exactly this reason, not only for the credential-scoping argument above.
A blank tab is almost always a missing annotation, not a broken plugin
An empty Kubernetes or Argo CD tab on an otherwise healthy entity is, nine times out of ten, a mismatch: backstage.io/kubernetes-id doesn't match the real pod labels, or argocd/app-name doesn't match the actual Argo CD Application name. Check the annotation against the live resource before you go looking for a bug in the plugin itself — the plugin is almost always fine, the string it was given almost always isn't.
"A team once paged me convinced the Kubernetes plugin was broken — the tab had shown nothing for a week. It wasn't the plugin. Someone had renamed a Deployment's app.kubernetes.io/name label during a cleanup and never touched backstage.io/kubernetes-id on the entity to match. The portal wasn't lying. It was faithfully reporting zero matching pods for the label it had actually been given — which is a boring, correct answer to a slightly wrong question nobody had asked it in a while."
Backstage vs. the alternatives
☺ Like you're 10: Backstage is the build-it-yourself shop, from a kit. Some companies buy a finished one instead, and small shops sometimes don't need a storefront at all yet.
Ask first whether you need a portal at all. With twenty services and one team, a good README and a make new-service script beat a Node monorepo you have to nurse — adopting a portal to create discovery demand, rather than to satisfy demand that already exists, is how portals become expensive screensavers nobody opens.
| Option | Shape | Strengths | Choose it when |
|---|---|---|---|
| Backstage (self-hosted) | Open framework you fork, extend, and run | No license cost, huge plugin ecosystem, unlimited customization, CNCF-governed | You have engineers to own a TypeScript app long-term, and want the portal shaped exactly like your platform |
| Backstage distributions (Red Hat Developer Hub, Spotify Portal, Roadie) | Backstage, packaged and supported | Same APIs and templates; upgrades and hardening become someone else's job | You want Backstage's model without personally owning its release lifecycle |
| Closed-source portals (Port, Cortex, OpsLevel, Harness IDP) | SaaS, configuration-driven | Fast to value, opinionated catalogs and scorecards out of the box, no app to maintain | Time-to-value matters more than customization, and a per-seat bill is acceptable |
CLI + templates (cookiecutter, a repo template) | No portal at all — a generator and a convention | Near-zero maintenance; developers who already live in a terminal don't notice the absence | Small org, or a culture that genuinely prefers a CLI to a web form |
| Git + CODEOWNERS + a wiki | The status quo | Free; already exists; nothing new to learn | Under roughly thirty services — but expect ownership rot as you grow past that |
Two habits de-risk the choice either way. Keep the source of truth in Git — entity descriptions beside the code, templates in a reviewable repository — so migrating portals later means re-pointing an ingester, not re-entering a thousand records by hand. And remember the portal is the thinnest layer in the whole stack: the value lives in the platform behind it, which is why this course's own certifications treat Backstage as its own narrow, single-product exam — see CBA — the exam — rather than folding it into the broader GitOps or mesh material. For the fuller build-versus-buy argument from a platform-engineering angle rather than an exam angle, Platform Engineering's own Backstage page and Platform as a Product go further than this page needs to.
Foxy: Leadership saw a Backstage demo. They want mission-portal live by Friday. It's a website, right? How hard can it be?
Mira: It's a TypeScript monorepo we fork, own, and upgrade forever. Friday gets you an empty catalog — and an empty catalog is worse than none. People try it once, find nothing, and never come back.
Nutty: I counted while Mira was talking — thirteen of the nineteen CBA competencies are code or a build step. This isn't a "point it at a cluster" tool.
Gizmo: Faster fix: skip the GitHub App, drop my own personal token into the scaffolder config, and turn off that permission-policy nonsense too. Ship it tonight! 🤑
Timmy: Two mistakes in one sentence, Gizmo. Your personal token dies the day you leave the team, and it's tied to you, not the platform. And with no permission policy, anyone who can log in can run that scaffolder and create repos under your name.
Benny: A scoped GitHub App and one permission policy is maybe a day of work. I'd rather spend Friday on that than explaining an incident on Monday.
Mira: Agreed. Seed the catalog with the fifty services people actually search for, ship one template that genuinely works, and measure who opens it. That's a launch. A blank shop with the doors open isn't.
1. What are the two packages a create-app produces, and which one is allowed to hold a secret? 2. In the new backend system, how is a capability like the catalog or the scaffolder added to a running backend? 3. In catalog-info.yaml, what's the difference between a declared field like spec.owner and a derived relation like ownedBy? 4. Name the three ways an entity gets into the catalog, and which one scales past a handful of services. 5. In a Software Template, what's the difference between parameters and steps, and what does the skeleton directory contain? 6. Your Kubernetes tab is blank on an otherwise healthy entity — what's the first thing to check, and what usually isn't the problem? 7. Name two things a fresh create-app leaves wide open by default, and what closes each one.
Check your answers
packages/app(the React frontend) andpackages/backend(the Node.js backend). Only the backend may hold a secret — anything shipped topackages/appreaches the browser and is public by definition.- It's registered with
backend.add(import('@backstage/plugin-...'))against onecreateBackend()instance — each capability is a self-contained module, resolving its own dependencies, rather than hand-wired into an Express app the way the legacy backend required. - A declared field like
spec.owner: group:default/platform-teamis something you write yourself in the YAML. A derived relation likeownedBy(and its reciprocalownerOfon the Group) is something Backstage computes automatically from that field — you never write the reverse edge by hand. - Manual registration (a human registers a URL, creating a
Location), static locations (URLs undercatalog.locationsin config), and entity providers that crawl an organization on a schedule. Only providers scale — and they also remove entities when the underlying file disappears, which locations never do on their own. parametersis JSON Schema rendered as the form the user fills in;stepsis the ordered list of server-side actions run on submit (fetch:template,publish:github,catalog:register…). The skeleton directory is the project tree the template renders — README, CI workflow,catalog-info.yaml— with${{ values.* }}placeholders substituted in.- Check whether
backstage.io/kubernetes-id(or the label selector variant) on the entity actually matches the live resource's labels — a mismatched string, not a broken plugin, causes nearly every blank tab of this kind. - Any two of: a guest sign-in provider (closed by wiring real SSO), an in-memory SQLite database (closed by configuring PostgreSQL), and an opt-in permission framework with no policy written (closed by enabling the permission backend and writing one before launch).