carlos / platform Public

domains: the operator's door for platform hostnames (#209)

The deny list now has two tiers. Platform names (CONSOLE_DENY_DOMAINS, and with
CONSOLE_RESERVE_PLATFORM_DOMAINS the names under the console's hostnames) are
refused to members, but an ops account owner or a CARLOS_SYSTEM_OPERATORS
address may attach one to an app in an account they belong to with
carlos domains attach --platform. Reserved names (console hosts, steering host,
tunnel host, the apps domain) stay refused to everyone; the tunnel host moves
out of DenyDomains into Config.TunnelHost so the door cannot reach it.

Once claimed, a platform name's claim is the operator's alone: members of the
holding account cannot repoint it, flip wildcard/catchall, publish paths or
detach it, through the API or the settings page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human

Prompt

Can we address carlos/platform #209 ?

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Two-tier deny list plus an operator-only --platform attach; members can't change an operator's platform claim afterwards.
Agent notes
Paths and detach gate hostPlatform only, not hostReserved, so a member holding a legacy claim on a now-reserved name can still release it. Copy reviewed and approved unchanged 2026-10-03.
claude agent, operated by paul@keymail.dev 9fb9cbf1b2bb17102ef82da292f88cbb1406f369 parent 78fc8d4
11 files changed, +605 −80
  • AGENTS.md +20 −0
  • README.md +1 −0
  • apimode.go +21 −9
  • cmd/console/main.go +21 −16
  • domains_cli.go +9 −3
  • domains_cli_test.go +15 −0
  • internal/console/apidomains.go +100 −41
  • internal/console/domainattach.go +28 −2
  • internal/console/domainplatform_test.go +295 −0
  • internal/console/domainpolicy.go +88 −9
  • internal/console/server.go +7 −0

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index d42abaf7a..cdca5b6a0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -423,3 +423,23 @@ usually keeps its console, its apps and its custom domains in one zone that
one person owns, and the rule refused ordinary names there. Every deployment
still refuses the console's own hostnames by exact name, the steering host,
CONSOLE_DENY_DOMAINS, and a hostname another app's route holds.
+
+## The operator's own hostnames (#209, Paul, 2026-10-03)
+
+The deny list has two tiers (domainHostPolicy, internal/console/domainpolicy.go).
+**Platform** names are CONSOLE_DENY_DOMAINS entries and, with
+CONSOLE_RESERVE_PLATFORM_DOMAINS, the names under the console's own hostnames.
+Members are refused them. A platform operator (an ops account owner, or an
+address on CARLOS_SYSTEM_OPERATORS) may attach one to an app in an account they
+are a member of, with `carlos domains attach --platform`. That is how carloku.com
+runs as an ordinary console app. **Reserved** names are refused to everyone,
+operators included: the console's own hostnames by exact name, the steering
+host, the tunnel host (Config.TunnelHost, kept out of DenyDomains so the door
+cannot reach it) and, with the reserve setting, the apps domain.
+
+Once a platform name is claimed, every later write to that claim is the
+operator's alone: a member of the holding account cannot repoint it, flip
+wildcard or catchall, publish its paths or detach it. These checks read the
+hostname, never the claim, so they reveal nothing about who holds it. The
+browser form has no platform door. Its attach refuses platform names to
+everyone, and its detach admits the operator.
diff --git a/README.md b/README.md
index c01f67ea8..334ef860b 100644
--- a/README.md
+++ b/README.md
@@ -488,6 +488,7 @@ publish an inflated instance count.
attach any name except the console's own hostname and
`CONSOLE_DENY_DOMAINS`. A value that is not a boolean stops the console at
boot.
+- `CONSOLE_DENY_DOMAINS`: comma-separated hostnames in the deployment's own domains (flagship: `carloku.com`). `carlos domains attach` refuses each one, and every name under it, to members. An owner of the ops account, or an address on `CARLOS_SYSTEM_OPERATORS`, can still attach one to an app with `carlos domains attach --platform`, and after that only they can change or detach it. The console's own hostnames, the steering host, the tunnel host and, with `CONSOLE_RESERVE_PLATFORM_DOMAINS`, everything under the apps domain stay refused to everyone.
- `PLATFORM_EDGE_IPS` — comma-separated edge IPs the DNS health checks
compare against, read by both the agent (checks) and the console
(guidance text), so the two can never name different addresses. Default:
diff --git a/apimode.go b/apimode.go
index 85c63470d..cd43df578 100644
--- a/apimode.go
+++ b/apimode.go
@@ -3681,15 +3681,27 @@ func (e *domainListError) Unwrap() error { return e.err }
// sees them. route and kind together are the door's 400 — an alias target
// and a first-route mint are two different instructions.
func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route, kind, channel string, wildcard, catchall *bool, messages ...string) (created bool, resolvedRoute string, err error) {
- in := struct {
- Host string `json:"host"`
- Route string `json:"route,omitempty"`
- Kind string `json:"kind,omitempty"`
- Channel string `json:"channel,omitempty"`
- Wildcard *bool `json:"wildcard,omitempty"`
- Catchall *bool `json:"catchall,omitempty"`
- Message string `json:"message,omitempty"`
- }{host, route, kind, channel, wildcard, catchall, firstMutationMessage(messages)}
+ return s.postDomainAttach(ctx, a, app, domainAttachIn{
+ Host: host, Route: route, Kind: kind, Channel: channel,
+ Wildcard: wildcard, Catchall: catchall, Message: firstMutationMessage(messages),
+ })
+}
+
+// domainAttachIn is the attach door's request body. Platform asks for the
+// operator's door (#209): a hostname in the deployment's own zones, which
+// the console grants only to an ops account owner or a system operator.
+type domainAttachIn struct {
+ Host string `json:"host"`
+ Route string `json:"route,omitempty"`
+ Kind string `json:"kind,omitempty"`
+ Channel string `json:"channel,omitempty"`
+ Wildcard *bool `json:"wildcard,omitempty"`
+ Catchall *bool `json:"catchall,omitempty"`
+ Message string `json:"message,omitempty"`
+ Platform bool `json:"platform,omitempty"`
+}
+
+func (s *apiSession) postDomainAttach(ctx context.Context, a apiAcct, app string, in domainAttachIn) (created bool, resolvedRoute string, err error) {
status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in)
if err != nil {
return false, "", err
diff --git a/cmd/console/main.go b/cmd/console/main.go
index b83594daf..9cc6220f7 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -293,6 +293,7 @@ func serve() {
SteeringHost: steeringHost(),
RedirectHosts: redirectHosts(),
DenyDomains: denyDomains(),
+ TunnelHost: tunnelHost(),
ReservePlatformDomains: reservePlatformDomains(),
StoreProvisioner: storeProvisioner,
StoreScanner: storeScanner,
@@ -887,13 +888,14 @@ func reservePlatformDomains() bool {
// denyDomains parses CONSOLE_DENY_DOMAINS (comma-separated) — hostnames, and
// everything under them, that `carlos domains attach` refuses (issue #151).
// This is the operator's list of names the console cannot derive on its own:
-// retired console origins, any other platform-managed domain. The edge's
-// reserved tunnel hostname is folded in below from CARLOS_TUNNEL_HOST. The
-// door itself adds the console's own hostnames, and, with
-// CONSOLE_RESERVE_PLATFORM_DOMAINS, PLATFORM_APPS_DOMAIN (hostDenied,
-// internal/console/apidomains.go). An unset variable is therefore a legal
-// deployment. Blank entries are dropped: an empty string in the
-// list would be a deny entry no hostname could avoid matching.
+// retired console origins, any other platform-managed domain. A platform
+// operator may still attach one of these to an app with --platform (#209).
+// The door itself adds the console's own hostnames, the tunnel host
+// (tunnelHost) and, with CONSOLE_RESERVE_PLATFORM_DOMAINS,
+// PLATFORM_APPS_DOMAIN (reservedDomains, internal/console/apidomains.go). An
+// unset variable is therefore a legal deployment. Blank entries are dropped:
+// an empty string in the list would be a deny entry no hostname could avoid
+// matching.
func denyDomains() []string {
var out []string
for _, d := range strings.Split(os.Getenv("CONSOLE_DENY_DOMAINS"), ",") {
@@ -901,18 +903,21 @@ func denyDomains() []string {
out = append(out, d)
}
}
- // The fleet hub's reserved hostname (CARLOS_TUNNEL_HOST — the edge's
- // variable, but host.env is one file per box) is Allowed by the edge and
- // is no registry route, so no derived deny entry names it; a tenant must
- // not attach it. #151 made it the operator's job to list; folding it in
- // here makes the MUST hold without anyone remembering. Env is read at
- // this layer only, as every other console setting is.
- if h := strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_TUNNEL_HOST"))); h != "" {
- out = append(out, strings.TrimSuffix(h, "."))
- }
return out
}
+// tunnelHost is the fleet hub's reserved hostname (CARLOS_TUNNEL_HOST — the
+// edge's variable, but host.env is one file per box). The edge Allows it and
+// no registry route names it, so no derived deny entry covers it; a tenant
+// must not attach it. #151 made it the operator's job to list. Passing it
+// to the console here makes the MUST hold without anyone remembering, and
+// passing it apart from CONSOLE_DENY_DOMAINS keeps it out of reach of the
+// operator's own --platform door (#209). Env is read at this layer only, as
+// every other console setting is.
+func tunnelHost() string {
+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_TUNNEL_HOST"))), ".")
+}
+
// storeScannerFor builds the object-storage virus-scanning engine from the
// environment, or returns nil — which is the ordinary shape. Scanning is
// opt-in and off by default: a deployment that sets nothing gets a nil
diff --git a/domains_cli.go b/domains_cli.go
index 569514ea0..0be68b260 100644
--- a/domains_cli.go
+++ b/domains_cli.go
@@ -19,12 +19,14 @@ import (
const domainsUsage = `usage: carlos domains <attach|detach|list|paths> [flags]
- attach --app <a> [--route <r>] [--wildcard] [--catchall] [flags] <hostname>
+ attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [flags] <hostname>
claim a customer's hostname for the app, pointed at
--route (default: the app's single instance record, if it
has exactly one — otherwise --route is required).
--wildcard/--catchall are capability flips: naming either
one changes it, an unset flag leaves it as it was.
+ --platform attaches a hostname in this deployment's own
+ domains. Platform operators only.
detach --app <a> [flags] <hostname>
release a hostname claim
list --app <a> [flags]
@@ -46,7 +48,7 @@ route, and writes it. The hostname comes live on the box that owns the route
at its next domains pass.
`
-const domainsAttachUsage = `usage: carlos domains attach --app <a> [--route <r>] [--wildcard] [--catchall] [--account <a>] [--account-id <id>] [--console <origin>] <hostname>
+const domainsAttachUsage = `usage: carlos domains attach --app <a> [--route <r>] [--wildcard] [--catchall] [--platform] [--account <a>] [--account-id <id>] [--console <origin>] <hostname>
`
const domainsDetachUsage = `usage: carlos domains detach --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <hostname>
@@ -87,6 +89,7 @@ func runDomainsAttach(args []string) {
route := fs.String("route", "", "route hostname to attach to")
wildcard := fs.Bool("wildcard", false, "enable a delegated wildcard cert for this hostname (needs a CNAME — see the 422 message if refused)")
catchall := fs.Bool("catchall", false, "derive a *.<host> catch-all alias to the same route (needs --wildcard)")
+ platform := fs.Bool("platform", false, "attach a hostname in this deployment's own domains (platform operators only)")
consoleFlag := consoleOption(fs, "console to attach through (default: the only logged-in one)")
acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account")
@@ -118,7 +121,10 @@ func runDomainsAttach(args []string) {
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, domainsAttachUsage)
ctx := context.Background()
- created, resolvedRoute, err := sess.domainAttach(ctx, acct, app2, host, strings.TrimSpace(*route), "", "", wildcardArg, catchallArg)
+ created, resolvedRoute, err := sess.postDomainAttach(ctx, acct, app2, domainAttachIn{
+ Host: host, Route: strings.TrimSpace(*route),
+ Wildcard: wildcardArg, Catchall: catchallArg, Platform: *platform,
+ })
if err != nil {
log.Fatalf("domains attach: %v", err)
}
diff --git a/domains_cli_test.go b/domains_cli_test.go
index 92872fe70..f3071c28b 100644
--- a/domains_cli_test.go
+++ b/domains_cli_test.go
@@ -267,6 +267,7 @@ type fakeDomainsConsole struct {
lastAttachBody struct {
Host, Route string
Wildcard, Catchall *bool
+ Platform bool
}
attachCreated bool
lastDetachPath string
@@ -730,3 +731,17 @@ func TestDomainsCLIUsageErrors(t *testing.T) {
})
}
}
+
+// TestRunDomainsAttachPlatformFlag: --platform asks for the operator's door
+// (#209) and is sent only when named.
+func TestRunDomainsAttachPlatformFlag(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ runDomains([]string{"attach", "-app", "tito", "tickets.example.com"})
+ if fc.lastAttachBody.Platform {
+ t.Fatalf("bare attach body = %+v, want platform unset", fc.lastAttachBody)
+ }
+ runDomains([]string{"attach", "-app", "tito", "--platform", "carloku.example.com"})
+ if !fc.lastAttachBody.Platform || fc.lastAttachBody.Host != "carloku.example.com" {
+ t.Fatalf("--platform body = %+v, want platform true", fc.lastAttachBody)
+ }
+}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index decd1f942..376751ba6 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -89,55 +89,92 @@ func (s *Server) consoleHosts() []string {
return hosts
}
-// deniedDomains are the entries whose whole subtree the /domains door
-// refuses. Every deployment gets two kinds of entry:
+// The /domains door refuses whole subtrees in two tiers (#209), and the
+// tier decides who may ever get past it.
//
-// - the operator's CONSOLE_DENY_DOMAINS entries (cmd/console adds the
-// tunnel hostname to those). An operator who lists a name has chosen to
-// refuse it;
-// - the STEERING HOST (default edge.<apps-domain>), which is platform
-// infrastructure and never a tenant's name.
+// platformDomains are the deployment's OWN zones. Members are refused them;
+// a platform operator may attach one to an app through the --platform door,
+// because these are exactly the names an operator runs their own site on
+// (carloku.com on the flagship, go.tito.io on Tito's):
//
-// With ReservePlatformDomains set (CONSOLE_RESERVE_PLATFORM_DOMAINS) the
-// console also adds the names it can derive from its own config (spec §5:
-// "all derived from console/deployment config, not hardcoded"). These are
-// the rules for a platform that hosts apps for OTHER people, such as the
-// flagship or carloku:
+// - the operator's CONSOLE_DENY_DOMAINS entries. An operator who lists a
+// name has chosen to refuse it to tenants;
+// - with ReservePlatformDomains, everything UNDER the console's own
+// hostnames (consoleHosts). On a shared platform those names belong to
+// the operator, not to a tenant.
//
-// - the APPS DOMAIN is the whole platform namespace
-// (<app>.<sqid>.<apps-domain>). Every app's public URL is under it, so a
-// claim there is one tenant taking another tenant's address. This is the
-// sibling-apex hazard that handleAPIInstanceCreate refuses within an
-// account, refused here across the deployment. Hosts under the apps
-// domain are what `carlos instances` is for;
-// - everything UNDER the console's own hostnames (consoleHosts). On a
-// shared platform those names belong to the operator, not to a tenant.
+// reservedDomains are refused to everyone, the operator included, because a
+// claim on any of them takes an address the platform itself or another
+// tenant stands on:
//
-// Without the setting, neither is added. That is the default because a
-// self-hosted deployment usually puts its console, its apps domain and its
-// custom domains in ONE zone that ONE person owns, and the reserved rules
-// refused ordinary names there. Existing routes are still safe without it:
+// - the STEERING HOST (default edge.<apps-domain>), platform
+// infrastructure;
+// - the TUNNEL HOST (CARLOS_TUNNEL_HOST), the fleet hub's own address,
+// which the edge Allows with no registry route behind it;
+// - with ReservePlatformDomains, the APPS DOMAIN, the whole tenant
+// namespace (<app>.<sqid>.<apps-domain>). Every app's public URL is
+// under it, so a claim there is one tenant taking another tenant's
+// address. This is the sibling-apex hazard that handleAPIInstanceCreate
+// refuses within an account, refused here across the deployment. Hosts
+// under the apps domain are what `carlos instances` is for.
+//
+// The console's own hostnames are refused by exact name, to everyone, in
+// domainHostPolicy.
+//
+// Without ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) neither
+// derived rule is added. That is the default because a self-hosted
+// deployment usually puts its console, its apps domain and its custom
+// domains in ONE zone that ONE person owns, and the reserved rules refused
+// ordinary names there. Existing routes are still safe without it:
// deploymentRouteTaken refuses a hostname that another app's route already
-// uses.
+// uses. With it set, these are the rules for a platform that hosts apps for
+// OTHER people, such as the flagship or carloku (spec §5: "all derived from
+// console/deployment config, not hardcoded").
//
// exemptAppsDomain is the CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS delegation
-// (domainHostDenied). It drops the apps domain, and every entry that is the
+// (domainHostPolicy). It drops, from both tiers, every entry that is the
// apps domain or an ancestor of it, so that a delegated one-label alias is
-// not refused by a blanket entry above it. The console's own hostnames stay
-// refused by exact name in domainHostDenied.
+// not refused by a blanket entry above it.
//
// Derived per call rather than cached at boot: one url.Parse and a short
// copy, on a door that then does bucket I/O. This keeps it correct for an
// embedder (or a test) that changes the config after NewServer.
-func (s *Server) deniedDomains(exemptAppsDomain bool) []string {
+func (s *Server) platformDomains(exemptAppsDomain bool) []string {
+ entries := slices.Clone(s.cfg.DenyDomains)
+ if s.cfg.ReservePlatformDomains {
+ entries = append(entries, s.consoleHosts()...)
+ }
+ return s.denyEntries(entries, exemptAppsDomain)
+}
+
+// reservedDomains: see platformDomains.
+func (s *Server) reservedDomains(exemptAppsDomain bool) []string {
domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
- covers := func(entry string) bool {
- return entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry))
+ steeringHost := strings.TrimSuffix(normalizeHost(strings.TrimSpace(s.cfg.SteeringHost)), ".")
+ if steeringHost == "" && domain != "" {
+ steeringHost = "edge." + domain
}
- entries := slices.Clone(s.cfg.DenyDomains)
+ entries := []string{strings.TrimSuffix(s.cfg.TunnelHost, ".")}
if s.cfg.ReservePlatformDomains {
entries = append(entries, domain)
- entries = append(entries, s.consoleHosts()...)
+ }
+ // The steering host is never exempt: it is edge.<apps-domain>, under
+ // the apps domain rather than above it, and a delegated alias account
+ // must not be able to take it.
+ deny := s.denyEntries(entries, exemptAppsDomain)
+ if steeringHost != "" {
+ deny = append(deny, steeringHost)
+ }
+ return deny
+}
+
+// denyEntries normalizes entries, dropping blanks (an empty entry would be
+// one no hostname could avoid matching) and, when exemptAppsDomain is set,
+// every entry that is the apps domain or an ancestor of it.
+func (s *Server) denyEntries(entries []string, exemptAppsDomain bool) []string {
+ domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
+ covers := func(entry string) bool {
+ return entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry))
}
deny := make([]string, 0, len(entries)+1)
for _, entry := range entries {
@@ -147,11 +184,7 @@ func (s *Server) deniedDomains(exemptAppsDomain bool) []string {
}
deny = append(deny, entry)
}
- steeringHost := strings.TrimSuffix(normalizeHost(strings.TrimSpace(s.cfg.SteeringHost)), ".")
- if steeringHost == "" && domain != "" {
- steeringHost = "edge." + domain
- }
- return append(deny, steeringHost)
+ return deny
}
// resolveAliasRoute decides which of the app's routes a claim points at.
@@ -308,6 +341,11 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// caller actually named.
Wildcard *bool `json:"wildcard,omitempty"`
Catchall *bool `json:"catchall,omitempty"`
+ // Platform asks for the operator's door (#209): with it, a hostname
+ // in the deployment's own zones (hostPlatform) may be attached. It
+ // lifts nothing else. Reserved names stay refused, and the caller
+ // must still be in this account (domainsGate).
+ Platform bool `json:"platform,omitempty"`
}
if !readJSON(w, r, &in) {
return
@@ -316,6 +354,12 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ // Decided from the caller and the flag alone, before the hostname is
+ // read, so the answer cannot vary with what the deny list holds.
+ if in.Platform && !s.principalPlatformOperator(p) {
+ jsonError(w, http.StatusForbidden, platformOperatorMsg)
+ return
+ }
// Path-traversal defence stated as such: this hostname becomes a bucket
// key segment (domains/<host>.json) and, on the box, a registry alias row
// and a certificate name. Nothing below runs until it is a well-formed,
@@ -326,7 +370,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
"invalid hostname — attach a full hostname like tickets.example.com")
return
}
- if s.domainHostDenied(host, acct.Sqid) || (s.directAppsDomainHost(host) && ((in.Wildcard != nil && *in.Wildcard) || (in.Catchall != nil && *in.Catchall))) {
+ if policy := s.domainHostPolicy(host, acct.Sqid); policy == hostReserved || (policy == hostPlatform && !in.Platform) || (s.directAppsDomainHost(host) && ((in.Wildcard != nil && *in.Wildcard) || (in.Catchall != nil && *in.Catchall))) {
jsonError(w, http.StatusBadRequest, "that hostname is platform-managed")
return
}
@@ -355,7 +399,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// from the core's result: on the first-route branch the host IS the
// route the record just declared; on the alias branch it is the
// route the claim names.
- res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal, message)
+ res, ref := s.attachDomainAs(ctx, acct, app, host, in.Kind, in.Channel, principal, in.Platform, message)
if ref != nil {
jsonError(w, ref.Status, ref.Msg)
return
@@ -432,7 +476,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
if changed {
s.auditActivity(acct, app, principal, Activity{
Action: "domain-attach", TargetType: "instance", Target: route, Message: message,
- Detail: "attached hostname " + host + " -> " + route + " (cli)",
+ Detail: "attached hostname " + host + " -> " + route + platformNote(in.Platform) + " (cli)",
})
// The claim is what a box's domains pass mints an alias row from, so
// this is a box-consumed write (carlos-deploy §3): announce it, and
@@ -646,6 +690,13 @@ func (s *Server) handleAPIDomainPaths(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid hostname")
return
}
+ // A prefix map on a platform hostname routes slices of the operator's
+ // own site, so it is the operator's to publish (#209). From the name
+ // alone, before the body or the claim is read: no neutrality cost.
+ if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.principalPlatformOperator(p) {
+ jsonError(w, http.StatusForbidden, platformOperatorMsg)
+ return
+ }
// A POINTER to the map, so that an ABSENT paths field is refused rather
// than read as an empty one. The distinction matters here in a way it
// does not on a merging door: this write is wholesale, so a body whose
@@ -781,6 +832,14 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid hostname")
return
}
+ // detachDomain's rule (#209): a platform hostname's claim is the
+ // operator's to release. From the name alone, before the claim is read,
+ // so the door's quiet-in-both-directions answer is untouched for every
+ // hostname a member could hold.
+ if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.principalPlatformOperator(p) {
+ jsonError(w, http.StatusForbidden, platformOperatorMsg)
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
// The first-route half (#82): a claim with an empty Route minted the
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index 59d6df883..4ec6cf467 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -95,6 +95,15 @@ type attachResult struct {
// a route, static otherwise) and the channel is the one the host's own
// existing route follows, or failing that the app's sole shipped channel.
func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, messages ...string) (attachResult, *doorRefusal) {
+ return s.attachDomainAs(ctx, acct, app, host, kindFlag, channelFlag, principal, false, messages...)
+}
+
+// attachDomainAs is attachDomain with the platform door (#209) named.
+// platform true lets a hostPlatform name through and nothing else; the
+// CALLER has already established that principal is a platform operator
+// (handleAPIDomainAttach refuses the flag otherwise). The browser form never
+// passes it.
+func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, platform bool, messages ...string) (attachResult, *doorRefusal) {
message := ""
if len(messages) != 0 {
message = messages[0]
@@ -111,7 +120,7 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
"not a valid hostname — attach a full hostname like tickets.example.com"}
}
- if s.domainHostDenied(host, acct.Sqid) {
+ if policy := s.domainHostPolicy(host, acct.Sqid); policy == hostReserved || (policy == hostPlatform && !platform) {
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
"that hostname is platform-managed"}
}
@@ -148,7 +157,7 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin
if changed {
s.auditActivity(acct, app, principal, Activity{
Action: "domain-attach", TargetType: "instance", Target: route, Message: message,
- Detail: host + " → " + route,
+ Detail: host + " → " + route + platformNote(platform),
})
s.bumpSerial(ctx)
}
@@ -268,6 +277,14 @@ func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, pri
if !ok {
return notAttached
}
+ // A platform hostname's claim is the operator's to release (#209).
+ // Decided from the name alone, before the claim is read, so it says
+ // nothing about who holds it. Reserved names are left alone: nobody can
+ // claim one now, and a member holding one from before it was reserved
+ // must still be able to let it go.
+ if s.domainHostPolicy(host, acct.Sqid) == hostPlatform && !s.platformOperator(principal) {
+ return &doorRefusal{http.StatusForbidden, platformOperatorMsg}
+ }
claim, err := domains.GetClaim(ctx, s.cfg.Releases, host)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@@ -497,3 +514,12 @@ func claimRefusal(host string, err error) *doorRefusal {
return &doorRefusal{http.StatusInternalServerError,
"could not claim this domain — try again in a moment"}
}
+
+// platformNote marks an audit row written through the platform door (#209),
+// so the holding account's activity log says the operator placed the claim.
+func platformNote(platform bool) string {
+ if platform {
+ return " (platform operator)"
+ }
+ return ""
+}
diff --git a/internal/console/domainplatform_test.go b/internal/console/domainplatform_test.go
new file mode 100644
index 000000000..a56f7cb73
--- /dev/null
+++ b/internal/console/domainplatform_test.go
@@ -0,0 +1,295 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+)
+
+// The platform door (#209): a deployment's operator attaching a hostname in
+// the deployment's own zones (CONSOLE_DENY_DOMAINS — carloku.com on the
+// flagship, go.tito.io on Tito's) to an app, with members still refused.
+
+// platformFixture is instanceFixture on a deployment that reserves platform
+// domains, lists carloku.test as its own, and has a tunnel host INSIDE that
+// zone — the case where the deny list's two halves must not be confused.
+// paul owns the ops account; alice is an ordinary member of it.
+func platformFixture(t *testing.T, code string) (fx *consoleFixture, alice, paul string) {
+ t.Helper()
+ fx, alice = instanceFixture(t, code)
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.ReservePlatformDomains = true
+ fx.srv.cfg.DenyDomains = []string{"carloku.test"}
+ fx.srv.cfg.TunnelHost = "hub.carloku.test"
+ return fx, alice, cliToken(t, fx, "paul@keymail.dev", code+"-paul")
+}
+
+func platformAttachBody(host, route string) string {
+ b, err := json.Marshal(map[string]any{"host": host, "route": route, "platform": true})
+ if err != nil {
+ panic(err)
+ }
+ return string(b)
+}
+
+func TestDomainHostPolicyMatrix(t *testing.T) {
+ s := &Server{cfg: Config{
+ AppsDomain: "apps.test",
+ ReservePlatformDomains: true,
+ Origin: "https://console.carloku.test",
+ RedirectHosts: []string{"retired.carloku.test"},
+ DenyDomains: []string{"carloku.test", "go.tito.test"},
+ TunnelHost: "hub.carloku.test",
+ }}
+ cases := []struct {
+ host string
+ want hostPolicy
+ }{
+ {"tickets.example.com", hostOpen},
+ {"carloku.test", hostPlatform},
+ {"www.carloku.test", hostPlatform},
+ {"go.tito.test", hostPlatform},
+ {"a.console.carloku.test", hostPlatform}, // under the console's own name
+ {"console.carloku.test", hostReserved}, // the console itself
+ {"retired.carloku.test", hostReserved}, // a retired console origin
+ {"hub.carloku.test", hostReserved}, // the tunnel host, though under a platform entry
+ {"x.hub.carloku.test", hostReserved},
+ {"edge.apps.test", hostReserved}, // the steering host
+ {"apps.test", hostReserved},
+ {"site.bab.apps.test", hostReserved}, // a tenant's address
+ }
+ for _, c := range cases {
+ if got := s.domainHostPolicy(c.host, "bab"); got != c.want {
+ t.Errorf("domainHostPolicy(%q) = %v, want %v", c.host, got, c.want)
+ }
+ if got := s.domainHostDenied(c.host, "bab"); got != (c.want != hostOpen) {
+ t.Errorf("domainHostDenied(%q) = %v, want %v", c.host, got, c.want != hostOpen)
+ }
+ }
+ // Without ReservePlatformDomains the apps domain and the console's
+ // subtree are ordinary names, as before; the operator's own list and the
+ // tunnel host are not.
+ s.cfg.ReservePlatformDomains = false
+ for host, want := range map[string]hostPolicy{
+ "site.bab.apps.test": hostOpen,
+ "a.console.carloku.test": hostPlatform, // still under carloku.test
+ "carloku.test": hostPlatform,
+ "hub.carloku.test": hostReserved,
+ } {
+ if got := s.domainHostPolicy(host, "bab"); got != want {
+ t.Errorf("without reserve: domainHostPolicy(%q) = %v, want %v", host, got, want)
+ }
+ }
+}
+
+// TestPlatformAttachRefusesMembers: an ordinary member gets the old refusal
+// without the flag and a 403 with it, and no claim is written either way.
+func TestPlatformAttachRefusesMembers(t *testing.T) {
+ fx, alice, _ := platformFixture(t, "code-plat-member")
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice,
+ attachBody("carloku.test", "go.tito.io")); code != http.StatusBadRequest {
+ t.Errorf("member attach without --platform = %d %s, want 400", code, out)
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice,
+ platformAttachBody("carloku.test", "go.tito.io"))
+ if code != http.StatusForbidden {
+ t.Errorf("member attach with --platform = %d %s, want 403", code, out)
+ }
+ // The flag is refused on an ordinary hostname too: the answer depends on
+ // the caller alone, never on what the deny list holds.
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), alice,
+ platformAttachBody("tickets.example.com", "go.tito.io")); code != http.StatusForbidden {
+ t.Errorf("member attach of an ordinary host with --platform = %d %s, want 403", code, out)
+ }
+ claimAbsent(t, fx, "carloku.test")
+ claimAbsent(t, fx, "tickets.example.com")
+}
+
+// TestPlatformAttachRefusesServices: an app token is never an operator, flag
+// or no flag.
+func TestPlatformAttachRefusesServices(t *testing.T) {
+ fx, _, _ := platformFixture(t, "code-plat-svc")
+ tok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), tok,
+ platformAttachBody("carloku.test", "go.tito.io"))
+ if code != http.StatusForbidden {
+ t.Errorf("app token attach with --platform = %d %s, want 403", code, out)
+ }
+ claimAbsent(t, fx, "carloku.test")
+}
+
+// TestPlatformAttachByOpsOwner: the ops owner needs the flag, and with it
+// gets an ordinary claim naming them as its author.
+func TestPlatformAttachByOpsOwner(t *testing.T) {
+ fx, _, paul := platformFixture(t, "code-plat-owner")
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul,
+ attachBody("carloku.test", "go.tito.io")); code != http.StatusBadRequest {
+ t.Fatalf("owner attach WITHOUT --platform = %d %s, want 400", code, out)
+ }
+ claimAbsent(t, fx, "carloku.test")
+ for _, host := range []string{"carloku.test", "www.carloku.test"} {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul,
+ platformAttachBody(host, "go.tito.io"))
+ if code != http.StatusOK {
+ t.Fatalf("owner attach %s with --platform = %d %s, want 200", host, code, out)
+ }
+ c := claimOf(t, fx, host)
+ if c.Account != fx.sqid || c.App != "titogo" || c.Route != "go.tito.io" || c.CreatedBy != "paul@keymail.dev" {
+ t.Errorf("claim %s = %+v", host, c)
+ }
+ }
+ // An operator's flag on an ordinary hostname is harmless: it attaches
+ // exactly as it would without.
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul,
+ platformAttachBody("tickets.example.com", "go.tito.io")); code != http.StatusOK {
+ t.Errorf("owner attach of an ordinary host with --platform = %d %s, want 200", code, out)
+ }
+}
+
+// TestPlatformAttachBySystemOperator: an address on CARLOS_SYSTEM_OPERATORS
+// may use the door on an account it is a member of, and only there; the ops
+// owner gets no reach into an account they are not in either.
+func TestPlatformAttachBySystemOperator(t *testing.T) {
+ fx, _, paul := platformFixture(t, "code-plat-sysop")
+ now := time.Now()
+ if err := fx.st.CreateAccount(42, "cdef", "carloku", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(42, "website", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(42, "bill@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ bill := cliToken(t, fx, "bill@keymail.dev", "code-plat-bill")
+
+ body := platformAttachBody("carloku.test", "website.cdef.oncarlos.test")
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), bill, body); code != http.StatusForbidden {
+ t.Fatalf("bill before being listed = %d %s, want 403", code, out)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), paul, body); code != http.StatusNotFound {
+ t.Fatalf("ops owner on an account they are not in = %d %s, want 404", code, out)
+ }
+ claimAbsent(t, fx, "carloku.test")
+
+ fx.srv.cfg.SystemOperators = []string{"bill@keymail.dev"}
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "cdef", "website"), bill, body); code != http.StatusOK {
+ t.Fatalf("listed system operator = %d %s, want 200", code, out)
+ }
+ if c := claimOf(t, fx, "carloku.test"); c.Account != "cdef" || c.App != "website" || c.CreatedBy != "bill@keymail.dev" {
+ t.Errorf("claim = %+v", c)
+ }
+}
+
+// TestPlatformAttachReservedStaysRefused: the door lifts the operator's own
+// list and nothing else. The console's names, the steering host, the tunnel
+// host and the tenant namespace stay refused to the operator too.
+func TestPlatformAttachReservedStaysRefused(t *testing.T) {
+ fx, _, paul := platformFixture(t, "code-plat-reserved")
+ fx.srv.cfg.Origin = "https://console.carloku.test"
+ for _, host := range []string{
+ "console.carloku.test",
+ "hub.carloku.test",
+ "edge.oncarlos.test",
+ "oncarlos.test",
+ "titogo.bab.oncarlos.test",
+ } {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul,
+ platformAttachBody(host, "go.tito.io"))
+ if code != http.StatusBadRequest {
+ t.Errorf("operator attach of reserved %s = %d %s, want 400", host, code, out)
+ }
+ claimAbsent(t, fx, host)
+ }
+ // The shared core behind the static first-route branch must draw the
+ // same line on its own, with the door already granted.
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("account: %v", err)
+ }
+ for _, host := range []string{"console.carloku.test", "hub.carloku.test", "edge.oncarlos.test", "titogo.bab.oncarlos.test"} {
+ if _, ref := fx.srv.attachDomainAs(context.Background(), acct, "titogo", host, "static", "", "paul@keymail.dev", true); ref == nil || ref.Status != http.StatusUnprocessableEntity {
+ t.Errorf("core attach of reserved %s with the door granted = %+v, want 422", host, ref)
+ }
+ claimAbsent(t, fx, host)
+ }
+}
+
+// TestPlatformClaimMembersCannotChangeIt: once the operator has attached a
+// platform hostname, the holding account's members cannot repoint it, flip
+// its capabilities, carve up its paths or release it. Only the operator can.
+func TestPlatformClaimMembersCannotChangeIt(t *testing.T) {
+ fx, alice, paul := platformFixture(t, "code-plat-hold")
+ const host = "carloku.test"
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul,
+ platformAttachBody(host, "go.tito.io")); code != http.StatusOK {
+ t.Fatalf("operator attach = %d %s", code, out)
+ }
+ before := claimOf(t, fx, host)
+
+ refusals := []struct {
+ what, method, url, body string
+ }{
+ {"repoint", http.MethodPost, domainsURL(fx, "bab", "titogo"), attachBody(host, "two.go.tito.io")},
+ {"repoint with --platform", http.MethodPost, domainsURL(fx, "bab", "titogo"), platformAttachBody(host, "two.go.tito.io")},
+ {"catchall", http.MethodPost, domainsURL(fx, "bab", "titogo"), `{"host":"` + host + `","route":"go.tito.io","wildcard":true,"catchall":true}`},
+ {"paths", http.MethodPut, pathsURL(fx, "bab", "titogo", host), `{"paths":{"admin":"go.tito.io"}}`},
+ {"detach", http.MethodDelete, domainsURL(fx, "bab", "titogo") + "/" + host, ""},
+ }
+ for _, r := range refusals {
+ code, _, out := rawReq(t, r.method, r.url, alice, r.body)
+ if code != http.StatusBadRequest && code != http.StatusForbidden {
+ t.Errorf("member %s = %d %s, want a refusal", r.what, code, out)
+ }
+ }
+ after := claimOf(t, fx, host)
+ if after.Route != before.Route || after.Wildcard || after.Catchall || len(after.Paths) != 0 {
+ t.Fatalf("claim changed under member writes: %+v", after)
+ }
+
+ if code, _, out := rawReq(t, http.MethodPut, pathsURL(fx, "bab", "titogo", host), paul,
+ `{"paths":{"admin":"go.tito.io"}}`); code != http.StatusOK {
+ t.Errorf("operator paths = %d %s, want 200", code, out)
+ }
+ code, _, out := rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/"+host, paul, "")
+ if code != http.StatusOK {
+ t.Fatalf("operator detach = %d %s, want 200", code, out)
+ }
+ var got domainDetachResp
+ if err := json.Unmarshal([]byte(out), &got); err != nil || !got.Removed {
+ t.Fatalf("operator detach body %s (err %v), want removed", out, err)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestPlatformBrowserDoors: the settings page offers no platform door. Its
+// attach refuses a platform hostname even to the operator, and its detach
+// lets the operator, and only the operator, release one.
+func TestPlatformBrowserDoors(t *testing.T) {
+ fx, _, paul := platformFixture(t, "code-plat-browser")
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("account: %v", err)
+ }
+ ctx := context.Background()
+ if _, ref := fx.srv.attachDomain(ctx, acct, "titogo", "carloku.test", "", "", "paul@keymail.dev"); ref == nil || ref.Status != http.StatusUnprocessableEntity {
+ t.Fatalf("browser attach by the operator = %+v, want 422", ref)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), paul,
+ platformAttachBody("carloku.test", "go.tito.io")); code != http.StatusOK {
+ t.Fatalf("operator attach = %d %s", code, out)
+ }
+ if ref := fx.srv.detachDomain(ctx, acct, "titogo", "carloku.test", "alice@keymail.dev"); ref == nil || ref.Status != http.StatusForbidden {
+ t.Fatalf("browser detach by a member = %+v, want 403", ref)
+ }
+ claimOf(t, fx, "carloku.test")
+ if ref := fx.srv.detachDomain(ctx, acct, "titogo", "carloku.test", "paul@keymail.dev"); ref != nil {
+ t.Fatalf("browser detach by the operator = %+v, want released", ref)
+ }
+ claimAbsent(t, fx, "carloku.test")
+}
diff --git a/internal/console/domainpolicy.go b/internal/console/domainpolicy.go
index 7147577ae..a5f7e2aa7 100644
--- a/internal/console/domainpolicy.go
+++ b/internal/console/domainpolicy.go
@@ -1,6 +1,7 @@
package console
import (
+ "log"
"slices"
"strings"
@@ -24,22 +25,100 @@ func (s *Server) directAppsDomainHost(host string) bool {
return label != "" && !strings.Contains(label, ".")
}
-// domainHostDenied reports whether an attach of host by account is refused
-// as platform-managed. Both /domains doors (the CLI API door and the console
-// form's attachDomain) ask this one question, so the two cannot drift. The
-// console's own hostnames are refused by exact name on every deployment
-// (consoleHosts); deniedDomains refuses whole subtrees.
-func (s *Server) domainHostDenied(host, account string) bool {
+// hostPolicy is the /domains doors' answer about a hostname, decided from
+// the name and the deployment's config alone, before any claim is read. It
+// can therefore never say anything about who holds a claim.
+type hostPolicy int
+
+const (
+ // hostOpen is an ordinary hostname: any member may attach it.
+ hostOpen hostPolicy = iota
+ // hostPlatform is in the deployment's own zones (platformDomains). Only
+ // a platform operator may attach it, through the --platform door, and
+ // only a platform operator may change or release a claim on it (#209).
+ hostPlatform
+ // hostReserved is refused to everyone, operators included
+ // (reservedDomains, and the console's own hostnames by exact name).
+ hostReserved
+)
+
+func (p hostPolicy) String() string {
+ switch p {
+ case hostOpen:
+ return "open"
+ case hostPlatform:
+ return "platform"
+ case hostReserved:
+ return "reserved"
+ }
+ return "unknown"
+}
+
+// domainHostPolicy classifies an attach of host by account. Every /domains
+// door (the CLI API door, the console form's attachDomain, and the paths and
+// detach doors) asks this one question, so they cannot drift. The console's
+// own hostnames are reserved by exact name on every deployment
+// (consoleHosts); reservedDomains and platformDomains cover whole subtrees,
+// and reserved wins where the two overlap (a tunnel host under a
+// CONSOLE_DENY_DOMAINS entry stays reserved).
+func (s *Server) domainHostPolicy(host, account string) hostPolicy {
if slices.Contains(s.consoleHosts(), host) {
- return true
+ return hostReserved
}
allowed := domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host)
if allowed {
domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
label := strings.TrimSuffix(host, "."+domain)
if domains.ValidSqid(label) {
- return true
+ return hostReserved
}
}
- return domains.HostDenied(host, s.deniedDomains(allowed))
+ if domains.HostDenied(host, s.reservedDomains(allowed)) {
+ return hostReserved
+ }
+ if domains.HostDenied(host, s.platformDomains(allowed)) {
+ return hostPlatform
+ }
+ return hostOpen
}
+
+// domainHostDenied reports whether a MEMBER's attach of host by account is
+// refused as platform-managed.
+func (s *Server) domainHostDenied(host, account string) bool {
+ return s.domainHostPolicy(host, account) != hostOpen
+}
+
+// platformOperator reports whether addr may use the platform door (#209):
+// attach a hostname in the deployment's own zones, and change or release a
+// claim on one. It is apiSystemRoller's rule, in its order: an owner of the
+// ops account, or an address on SystemOperators (CARLOS_SYSTEM_OPERATORS).
+// The owner check comes first and a store error is not the end, so a listed
+// operator still gets in on a console whose role lookup failed, and a store
+// error is never what admits anyone.
+//
+// It grants no reach into an account: every door that asks it has already
+// established that addr may act on the account and app in question.
+func (s *Server) platformOperator(addr string) bool {
+ if addr == "" {
+ return false
+ }
+ role, err := s.store.Role(OpsAccountID, addr)
+ if err != nil {
+ log.Printf("console: platform operator: role: %v", err)
+ }
+ if err == nil && role == RoleOwner {
+ return true
+ }
+ return s.systemOperator(addr)
+}
+
+// principalPlatformOperator is platformOperator for an API caller: only a
+// person can be one. A service credential acts for an app, never for the
+// deployment.
+func (s *Server) principalPlatformOperator(p Principal) bool {
+ return p.Kind == PrincipalUser && s.platformOperator(p.Address)
+}
+
+// platformOperatorMsg is the refusal for a caller who asks for the platform
+// door, or writes to a platform hostname's claim, without being an operator.
+const platformOperatorMsg = "that hostname is platform-managed. only a platform operator (an ops account owner, or an address on CARLOS_SYSTEM_OPERATORS) can attach or change it"
diff --git a/internal/console/server.go b/internal/console/server.go
index 072bb3a45..4651214b7 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -544,6 +544,13 @@ type Config struct {
// not already derivable from this config. Empty is legal. The console's
// own hostnames are refused regardless (consoleHosts, apidomains.go).
DenyDomains []string
+ // TunnelHost is the fleet hub's reserved hostname (CARLOS_TUNNEL_HOST).
+ // The /domains door refuses it, and everything under it, to everyone.
+ // It is kept apart from DenyDomains because the operator's platform
+ // door (#209) lifts DenyDomains and must never lift this: the edge
+ // Allows the name and no registry route backs it, so a claim on it
+ // would take the fleet's own address.
+ TunnelHost string
// ReservePlatformDomains (CONSOLE_RESERVE_PLATFORM_DOMAINS) is the
// opt-in for a deployment that hosts apps for other people, such as the
// flagship or carloku. With it set, the /domains door also refuses