package web import "strings" // The operator route table (hub v0.152.0, `05` §17). EVERY case of ServeHTTP's route switch has one entry here, // in the SAME ORDER and with the SAME match (exact / prefix / suffix / contains), and the classifier walks the // entries first-match-wins exactly as the switch does — so the class a request gets is the class of the handler // that serves it. TestRouteTable_MatchesServeHTTPSwitch reads the switch from server.go's syntax tree and fails // when a case is added, removed, reordered or changed without this table following (R-952, web side). // // What a class means for an ACCESS TOKEN (a session and Basic+header stay full operator, unchanged): // - sessionOnly — never by any token: a secret is shown or minted, something is deleted or reset, the password or // the tokens themselves change, or no group fits (listed in `05` §17 for the operator to place). // - anyToken — any valid token (static assets of the page itself). // - exempt — not an operator route at all (/login, the public /bind/); the token is not consulted. // - a group + read/write — the token needs ":" (write includes read). // // GET and HEAD use the entry's get class; every other method uses post. A method the entry does not name is // sessionOnly — the fail-safe direction, and the handler answers 405 to it anyway. // Token scope groups. The keys are stored in api_tokens.scopes; the labels are the Configuration card's words. const ( grpOverview = "overview" grpFloor = "floor" grpVouch = "vouch" grpCustomers = "customers" grpHosts = "hosts" grpOffsite = "offsite" grpOS = "os" grpMailHold = "mailhold" ) // scopeGroup is one row of the Configuration card. Read/Write say which ticks it offers: a tick is offered only when // it opens at least one route (TestScopeGroups_EveryOfferedTickOpensARoute). type scopeGroup struct { Key, Label, ReadHelp, WriteHelp string Read, Write bool } var scopeGroups = []scopeGroup{ {grpOverview, "Overview", "dashboard, customer list, hosts list, apps, off-site, system (no secrets)", "", true, false}, {grpFloor, "Releases: floor", "the floors (Configuration page, floor impact)", "the global and the per-customer floor", true, true}, {grpVouch, "Releases: vouch", "", "golden, agent, MinAgent (the vouch)", false, true}, {grpCustomers, "Customers", "config forms, DR recipe", "block/unblock, geo disable", true, true}, {grpHosts, "Hosts & logs", "host pages, log bundles, log tails", "operator actions, log requests", true, true}, {grpOffsite, "Off-site", "run the key check", "windows, freeze", true, true}, {grpOS, "OS updates", "the fleet queue", "approve, ring, on/off", true, true}, {grpMailHold, "Mail hold", "", "release", false, true}, } type routeClass struct { Group string // "" unless group-scoped Write bool SessionOnly bool AnyToken bool Exempt bool } var ( sessionOnly = routeClass{SessionOnly: true} anyToken = routeClass{AnyToken: true} exempt = routeClass{Exempt: true} ) func rd(g string) routeClass { return routeClass{Group: g} } func wr(g string) routeClass { return routeClass{Group: g, Write: true} } // scope is the ":" string a group-scoped class needs. func (c routeClass) scope() string { if c.Write { return c.Group + ":write" } return c.Group + ":read" } // routeEntry mirrors one disjunct of one switch case. bindFn marks the isPublicBindPath case. type routeEntry struct { Exact, Prefix, Suffix, Contains string BindFn bool Get, Post routeClass } func (e routeEntry) matches(path string) bool { if e.BindFn { return isPublicBindPath(path) } if e.Exact != "" && path != e.Exact { return false } if e.Prefix != "" && !strings.HasPrefix(path, e.Prefix) { return false } if e.Suffix != "" && !strings.HasSuffix(path, e.Suffix) { return false } if e.Contains != "" && !strings.Contains(path, e.Contains) { return false } return true } var none = routeClass{} // zero: the method is not served → treated as sessionOnly // operatorRoutes is the table. Order and match are pinned to server.go's switch by the test named above. var operatorRoutes = []routeEntry{ {Exact: "/", Get: rd(grpOverview)}, {Exact: "/style.css", Get: anyToken}, {Exact: "/static/chart.min.js", Get: anyToken}, {Prefix: "/static/fonts/", Get: anyToken}, // The Configuration page shows the floors; its POST is the asset re-seed, which fits no group. {Exact: "/configuration", Get: rd(grpFloor), Post: sessionOnly}, {Exact: "/apps", Get: rd(grpOverview)}, {Exact: "/apps/", Get: rd(grpOverview)}, // Telemetry reset and issue dismissal change the fleet view; no group fits them. {Prefix: "/apps/", Suffix: "/reset-telemetry", Post: sessionOnly}, {Prefix: "/apps/", Suffix: "/dismiss-issues", Post: sessionOnly}, {Prefix: "/apps/", Get: rd(grpOverview)}, {Exact: "/offsite", Get: rd(grpOverview)}, // The WireGuard endpoint record is infrastructure; no group fits a save. {Exact: "/offsite/endpoints", Post: sessionOnly}, {Prefix: "/offsite/endpoints/", Suffix: "/delete", Post: sessionOnly}, {Exact: "/hosts", Get: rd(grpOverview)}, {Exact: "/hosts/", Get: rd(grpOverview)}, {Exact: "/system", Get: rd(grpOverview)}, // A bind delivers the household's retrieval passphrase to the box that asks — a secret delivery. {Prefix: "/appliances/", Suffix: "/bind", Post: sessionOnly}, {Prefix: "/appliances/", Suffix: "/discard", Post: sessionOnly}, {Prefix: "/hosts/", Suffix: "/delete-impact", Get: sessionOnly}, {Prefix: "/hosts/", Suffix: "/delete", Post: sessionOnly}, {Prefix: "/hosts/", Suffix: "/reveal-recovery-credential", Post: sessionOnly}, {Prefix: "/hosts/", Suffix: "/operator-action", Post: wr(grpHosts)}, {Prefix: "/hosts/", Suffix: "/request-logs", Post: wr(grpHosts)}, {Prefix: "/hosts/", Contains: "/log-bundles/", Get: rd(grpHosts)}, {Prefix: "/hosts/", Get: rd(grpHosts)}, {Exact: "/login", Get: exempt, Post: exempt}, {BindFn: true, Get: exempt, Post: exempt}, {Prefix: "/customers/", Suffix: "/block", Post: wr(grpCustomers)}, // The self-bind link is a capability that binds a box to the household — minted here. {Prefix: "/customers/", Suffix: "/selfbind-link", Post: sessionOnly}, {Prefix: "/customers/", Suffix: "/unblock", Post: wr(grpCustomers)}, {Prefix: "/customers/", Suffix: "/geo/disable", Post: wr(grpCustomers)}, {Prefix: "/customers/", Suffix: "/floor", Post: wr(grpFloor)}, // Mints the household's owner passphrase and controller API key (operator ruling 2026-10-15 13:20, `09` §3 252). {Prefix: "/customers/", Suffix: "/create-config", Post: sessionOnly}, {Prefix: "/customers/", Suffix: "/dr-recipe.json", Get: rd(grpCustomers)}, {Prefix: "/customers/", Suffix: "/request-log-tail", Post: wr(grpHosts)}, {Prefix: "/customers/", Contains: "/log-tail/", Get: rd(grpHosts)}, // The customer page embeds the owner passphrase and the controller API key (customer_unified.html). {Prefix: "/customers/", Get: sessionOnly, Post: sessionOnly}, {Exact: "/configs", Get: rd(grpOverview)}, // Create mints the passphrase + API key, takes the household's e-mail and mails the self-bind link (ruling 13:20). {Exact: "/configs/new", Get: rd(grpCustomers), Post: sessionOnly}, {Exact: "/configuration/global-floor/impact", Get: rd(grpFloor)}, {Exact: "/configuration/global-floor", Post: wr(grpFloor)}, {Exact: "/configuration/artifacts", Post: wr(grpVouch)}, {Exact: "/configuration/mail-hold/release", Post: wr(grpMailHold)}, {Exact: "/configuration/password", Post: sessionOnly}, {Exact: "/configuration/tokens", Post: sessionOnly}, {Prefix: "/configuration/tokens/", Suffix: "/revoke", Post: sessionOnly}, {Prefix: "/configs/", Suffix: "/delete", Get: sessionOnly, Post: sessionOnly}, // Edit can change the household's e-mail — and a new e-mail auto-mails the self-bind link (ruling 13:20). {Prefix: "/configs/", Suffix: "/edit", Get: rd(grpCustomers), Post: sessionOnly}, {Prefix: "/offsite/remove-unpinned/", Post: sessionOnly}, // Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only. {Prefix: "/offsite/abandon-cancel/", Post: sessionOnly}, {Prefix: "/offsite/window-grant/", Post: wr(grpOffsite)}, {Exact: "/offsite/windows-enabled", Post: wr(grpOffsite)}, {Prefix: "/os/", Get: rd(grpOS), Post: wr(grpOS)}, // Runs the daily key check now: it reads keys and reports, it changes none. {Exact: "/offsite/key-audit", Post: rd(grpOffsite)}, // Resets the household's off-site password and stages a fresh one-time secret for the box (R-1059, ruling 2026-10-15). {Prefix: "/configs/", Suffix: "/offsite-reissue", Post: sessionOnly}, // Sends a fresh claim code to the household's e-mail (R-1055, operator ruling 2026-10-15 13:20). {Prefix: "/configs/", Suffix: "/claim-resend", Post: sessionOnly}, // Re-keys the household's PBS-DR token and stages a fresh one-time secret for the box (R-1059, ruling 2026-10-15). {Prefix: "/configs/", Suffix: "/pbsdr-reissue", Post: sessionOnly}, {Prefix: "/configs/", Suffix: "/offsite-freeze", Post: wr(grpOffsite)}, // The preview is the generated controller config — it carries the controller API key. {Prefix: "/configs/", Suffix: "/preview", Get: sessionOnly}, {Prefix: "/configs/", Suffix: "/regen-password", Post: sessionOnly}, {Prefix: "/configs/", Suffix: "/reset", Get: sessionOnly, Post: sessionOnly}, {Prefix: "/configs/", Get: rd(grpOverview), Post: rd(grpOverview)}, } // classifyRoute returns the class of the handler that serves (method, path). A path no entry matches is the // switch's 404 — sessionOnly, so an unknown path never answers a token differently from a missing scope. func classifyRoute(method, path string) routeClass { for _, e := range operatorRoutes { if !e.matches(path) { continue } c := e.Post if method == "GET" || method == "HEAD" { c = e.Get } if c == none { return sessionOnly } return c } return sessionOnly } // validScope reports whether s is a scope the Configuration card offers. func validScope(s string) bool { for _, g := range scopeGroups { if (g.Read && s == g.Key+":read") || (g.Write && s == g.Key+":write") { return true } } return false } // tokenAllows reports whether a token holding scopes may use class c. write includes read. func tokenAllows(scopes []string, c routeClass) bool { switch { case c.Exempt, c.AnyToken: return true case c.SessionOnly, c.Group == "": return false } for _, s := range scopes { if s == c.scope() || (!c.Write && s == c.Group+":write") { return true } } return false }