feat(operator): gate op.console to staff and land owner setup there

The operator console (op.console.<root>) requires internal permission
verification on top of Zero-Trust: a passkey is not access. requireExternal
now refuses any non-admin principal arriving on the admin host, before any
handler, so op.console is staff-only at the door rather than per-route —
including on the passwordless demo face where Cloudflare Access is not in
front. The gate is inert on the player console (console.<root>).

Owner first-run setup is staff onboarding, so `felis setup` mints the
one-time setup URL on op.console.<root>/setup (was console.<root>). The
passkey verifier lists both console and op.console in RPOrigins so the
one-time binding asserts on either face under the shared console.<root>
RP-ID.

Session admin-access now includes role=owner, not only admin: the owner is
a superset of admin, so excluding it left IsOwner() unreachable through a
passwordless session. No path assigns role=owner yet — this is forward
consistency.

The bootstrap summary now names console.<root> the player panel and
op.console.<root> the operator console where the Owner runs setup, fixing
text that told operators not to run setup there.

Tests: op.console door gate (non-admin refused, player console unaffected,
admin passes) and owner session admin-access; the setup-bind default-host
test follows the move to op.console.
This commit is contained in:
flyemoji committed 2026-07-16 18:03:30 +09:00
1 parent 26b62e91cd
commit 60732a6283
11 files changed
+127 -35

No files matched your search

+5 -3
View File
@@ -778,14 +778,16 @@ func TestPerformSetupMCBind(t *testing.T) {
}
})
t.Run("defaults the console host when panelHostname is empty", func(t *testing.T) {
t.Run("defaults to the op.console host when adminHostname is empty", func(t *testing.T) {
f := &fakeOwnerStore{redeemUserID: "usr-owner-1"}
out, err := performSetupMCBind(ctx, f, "abc-123", " ", "root")
if err != nil {
t.Fatalf("performSetupMCBind: %v", err)
}
if !strings.HasPrefix(out.setupTokenURL, "https://console.localhost/setup?token=") {
t.Errorf("setup URL = %q, want the console.localhost default host", out.setupTokenURL)
// The Owner is staff, so onboarding lands on the operator console, not the
// player panel — the empty-host fallback must reflect that.
if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") {
t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL)
}
})
}