diff --git a/internal/behaviour/clientlogin_test.go b/internal/behaviour/clientlogin_test.go index 4babd69..96d9d04 100644 --- a/internal/behaviour/clientlogin_test.go +++ b/internal/behaviour/clientlogin_test.go @@ -232,6 +232,102 @@ func clientLoginPhases(t *testing.T, j *journey) { statusOnly(t, replay, http.StatusUnauthorized) }) + phase(t, "a second application is signed in without asking again", func(t *testing.T) { + // The single sign-on property: once Go Loose holds a session for this person + // in this tenant, another application on the same tenant must receive a + // code straight away, with no sign-in page in between. + type application struct { + ID string `json:"id"` + ClientID string `json:"client_id"` + RedirectURI []string `json:"redirect_uris"` + } + type setupResponse struct { + Application application `json:"application"` + Secret string `json:"client_secret"` + } + created := decode[application](t, j.admin.json(http.MethodPost, host, "/api/v1/applications", map[string]any{ + "tenant_id": j.scopedTenantID, "slug": "second-app", "name": "Second App", + "allowed_hosts": []string{"second.example.test"}, + }), http.StatusCreated) + setup := decode[setupResponse](t, j.admin.json(http.MethodPost, host, + "/api/v1/applications/"+created.ID+"/client-config", map[string]any{ + "redirect_uris": []string{"https://second.example.test/callback"}, + }), http.StatusOK) + if setup.Application.ClientID == "" || setup.Secret == "" { + t.Fatal("the second application was not configured for client login") + } + + // The member needs access to it, the same as any other application. + access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.memberID+"/access", map[string]any{ + "tenant_id": j.scopedTenantID, "role_id": j.viewerRoleID, + "application_ids": []string{j.applicationID, created.ID}, + }) + if access.StatusCode != http.StatusNoContent { + t.Fatalf("grant the second application = %d: %s", access.StatusCode, bodyOf(t, access)) + } + + // Sign in once, through the first application. + browser := j.browser() + tenantHost := j.h.tenantHost(j.scopedTenant) + firstQuery := "response_type=code&client_id=" + j.clientID + + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&state=first" + signIn := location(t, browser.get(tenantHost, "/connect/authorize?"+firstQuery, nil)) + token := csrfFrom(t, bodyOf(t, browser.get(tenantHost, signIn, nil))) + response := browser.form(tenantHost, "/auth/password", neturlValues(map[string]string{ + "csrf_token": token, "email": j.memberEmail, "password": j.memberPassword, + "return": returnParam(t, signIn), + }), nil) + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response)) + } + if response := browser.get(tenantHost, "/connect/authorize?"+firstQuery, nil); response.StatusCode != http.StatusFound { + t.Fatalf("first application = %d", response.StatusCode) + } else { + response.Body.Close() + } + + // The second application must not see a sign-in page. + secondQuery := "response_type=code&client_id=" + setup.Application.ClientID + + "&redirect_uri=" + url.QueryEscape("https://second.example.test/callback") + "&state=second" + response = browser.get(tenantHost, "/connect/authorize?"+secondQuery, nil) + if response.StatusCode != http.StatusFound { + t.Fatalf("second application = %d, want a code with no prompt: %s", + response.StatusCode, bodyOf(t, response)) + } + callback, err := url.Parse(location(t, response)) + if err != nil { + t.Fatalf("parse callback: %v", err) + } + if callback.Host != "second.example.test" { + t.Errorf("callback host = %q", callback.Host) + } + if callback.Query().Get("code") == "" { + t.Error("no authorization code for the second application") + } + if callback.Query().Get("state") != "second" { + t.Errorf("state = %q", callback.Query().Get("state")) + } + + // And it identifies the same person, with no further prompt. + exchange := j.browser().request(http.MethodPost, j.h.apex(), "/connect/token", + strings.NewReader(url.Values{ + "grant_type": {"authorization_code"}, "code": {callback.Query().Get("code")}, + "redirect_uri": {"https://second.example.test/callback"}, + }.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + basicAuth(setup.Application.ClientID, setup.Secret), + }) + type secondToken struct { + User struct { + Email string `json:"email"` + } `json:"user"` + } + granted := decode[secondToken](t, exchange, http.StatusOK) + if granted.User.Email != j.memberEmail { + t.Errorf("the second application saw %q, want %s", granted.User.Email, j.memberEmail) + } + }) + phase(t, "the token identifies the user at the userinfo endpoint", func(t *testing.T) { type identity struct { ID string `json:"id"` diff --git a/internal/server/theme_test.go b/internal/server/theme_test.go index d7385f2..e37d4b9 100644 --- a/internal/server/theme_test.go +++ b/internal/server/theme_test.go @@ -1,8 +1,11 @@ package server import ( + "fmt" "strings" "testing" + + "github.com/danyel/go-loose/web" ) // The theme origin is substituted into every HTML page. These tests cover the two @@ -89,3 +92,79 @@ func TestThemeOriginLeavesNonThemePagesAlone(t *testing.T) { t.Errorf("a page without theme lines was modified:\n%s", got) } } + +// contractBackedTokens maps this application's own colour token onto the shared +// contract role that carries the same meaning. The right-hand side is a bare role +// name because the variable prefix belongs to the contract. +var contractBackedTokens = map[string]string{ + "--bg": "bg", + "--surface": "surface", + // go-loose's second surface step is the contract's raised surface. + "--surface2": "surface-raised", + "--text": "text", + "--muted": "muted", + "--accent": "accent", + // go-loose's secondary accent is the contract's secondary role: for every + // palette the two agree, tokyo's #bb9af7 and catppuccin's #cba6f7 among them. + "--accent2": "secondary", + "--good": "good", + "--danger": "danger", + "--border": "border", +} + +// TestLocalTokensAdoptTheContractWhereItIsServed covers the difference between a +// theme selector that is present in the page and one that works. +// +// Every palette this application names has to resolve through the contract's +// variables, with this application's own value as the fallback. The fallback is +// what keeps the install and login pages styled as they always were: they never load +// the contract, so the variable is undefined there and the literal applies. Loading +// the contract on a console page therefore adopts the shared palette without +// changing anything for the pages that do not ask for it. +// +// Both directions are asserted. Adopting the role is not enough on its own: a single +// declaration left holding a bare literal overrides the contract for whichever +// palette that block names, and the console then renders half shared and half local. +func TestLocalTokensAdoptTheContractWhereItIsServed(t *testing.T) { + css, err := web.Files.ReadFile("style.css") + if err != nil { + t.Fatalf("style.css must be embedded: %v", err) + } + stylesheet := string(css) + + for token, role := range contractBackedTokens { + if !strings.Contains(stylesheet, fmt.Sprintf("%s:var(--bn-%s,", token, role)) { + t.Errorf("token %s does not adopt --bn-%s, so selecting a palette outside this application's own four changes nothing:\n%s", + token, role, firstLines(css, 8)) + } + // A literal assignment anywhere would win for the palette it sits under, and + // the contract's value for that one palette would be discarded. + if literal := token + ":#"; strings.Contains(stylesheet, literal) { + t.Errorf("token %s is still assigned a bare literal, which overrides the contract for the palette that block names:\n%s", + token, firstLines(css, 8)) + } + } +} + +// TestTheShadowTokenIsNotMapped guards the one token deliberately left alone. +// The contract's shadow family holds complete box-shadow values, and this token +// holds a colour, so wiring them together would substitute a shadow for a colour. +func TestTheShadowTokenIsNotMapped(t *testing.T) { + css, err := web.Files.ReadFile("style.css") + if err != nil { + t.Fatalf("style.css must be embedded: %v", err) + } + if strings.Contains(string(css), "--shadow:var(--bn-") { + t.Error("--shadow is a colour here and the contract's shadow roles are box-shadow values; mapping them would break every elevation") + } +} + +// firstLines trims a stylesheet for an error message, since the token blocks sit at +// the top of the file and the whole of it is noise in a test failure. +func firstLines(css []byte, n int) string { + lines := strings.SplitN(string(css), "\n", n+1) + if len(lines) > n { + lines = lines[:n] + } + return strings.Join(lines, "\n") +} diff --git a/internal/store/application_access_integration_test.go b/internal/store/application_access_integration_test.go new file mode 100644 index 0000000..835dbdd --- /dev/null +++ b/internal/store/application_access_integration_test.go @@ -0,0 +1,121 @@ +package store_test + +import ( + "errors" + "testing" + + "github.com/danyel/go-loose/internal/store" +) + +// TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications pins the +// boundary between the management plane and the client plane. +// +// Owning every tenant is what lets the first administrator reach the whole +// installation from the console. It is not permission to sign in to the +// applications running inside those tenants: ClientForAuthorization answers for a +// user_application_access row and nothing else, so a claim that inserted one per +// application would ship every installation with a standing administrator +// account inside every customer application. Access to an application is an +// explicit grant, and this test also pins that such a grant is still possible on +// purpose, because an administrator is allowed to attach themselves. +func TestSystemAdministratorIsNotAutomaticallyInsideTenantApplications(t *testing.T) { + s, _, ctx := integrationStore(t) + + // Two tenants, each with a client configured, so that "every application" is + // not satisfied by a single row. + firstOwner := newUser(t, s, ctx, "first@example.test") + first := newTenant(t, s, ctx, firstOwner, "nmbs") + secondOwner := newUser(t, s, ctx, "second@example.test") + second := newTenant(t, s, ctx, secondOwner, "ypto") + + const redirect = "https://app.example.test/callback" + application := func(owner, tenantID, slug string) store.Application { + t.Helper() + created, err := s.CreateApplication(ctx, owner, store.Application{ + TenantID: tenantID, Slug: slug, Name: slug, AllowedHosts: []string{}, + }) + if err != nil { + t.Fatalf("create application %s: %v", slug, err) + } + if _, err := s.ConfigureClient(ctx, owner, created.ID, []string{redirect}, secretHash(slug)); err != nil { + t.Fatalf("configure client login for %s: %v", slug, err) + } + return created + } + firstApp := application(firstOwner, first.ID, "guess") + secondApp := application(secondOwner, second.ID, "tell") + + admin := newUser(t, s, ctx, "admin@example.test") + claimed, err := s.ClaimFirstSystemAdministrator(ctx, admin) + if err != nil { + t.Fatalf("claim first system administrator: %v", err) + } + if !claimed { + t.Fatal("the first user to sign in must become the system administrator") + } + + // The management plane still reaches everything: the claim owns both tenants, + // which is what the console needs to administer them. + console, err := s.HasConsoleAccess(ctx, admin) + if err != nil { + t.Fatalf("HasConsoleAccess: %v", err) + } + if !console { + t.Error("a system administrator must have console access") + } + tenants, err := s.ListTenants(ctx, admin) + if err != nil { + t.Fatalf("ListTenants: %v", err) + } + if len(tenants) != 2 { + t.Errorf("the administrator owns %d tenants, want 2", len(tenants)) + } + + // The client plane does not. No application was granted, so neither endpoint + // that mints or resolves a hosted-login session will recognize them. + for _, test := range []struct { + name string + application store.Application + }{ + {name: "nmbs", application: firstApp}, + {name: "ypto", application: secondApp}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := s.ClientForAuthorization(ctx, test.application.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("ClientForAuthorization = %v, want ErrNotFound: owning a tenant must not open its applications", err) + } + if _, err := s.ClientUserByToken(ctx, secretHash("no-session")); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("ClientUserByToken = %v, want ErrNotFound", err) + } + }) + } + + // Attaching the administrator to one application is allowed, and it is the only + // way in. They hold users.manage in every tenant by virtue of owning it, which + // is what makes the deliberate act possible. + ownerRole, err := s.SystemRoleID(ctx, "owner") + if err != nil { + t.Fatalf("look up the owner role: %v", err) + } + if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, []string{firstApp.ID}); err != nil { + t.Fatalf("an administrator must be able to attach themselves to an application: %v", err) + } + if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); err != nil { + t.Fatalf("ClientForAuthorization after an explicit grant = %v, want the application", err) + } + + // Withdrawing it takes effect immediately and leaves the console untouched. + if err := s.SetUserAccess(ctx, admin, first.ID, admin, ownerRole, nil); err != nil { + t.Fatalf("withdraw the grant: %v", err) + } + if _, err := s.ClientForAuthorization(ctx, firstApp.ClientID, redirect, admin); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("ClientForAuthorization after withdrawal = %v, want ErrNotFound", err) + } + console, err = s.HasConsoleAccess(ctx, admin) + if err != nil { + t.Fatalf("HasConsoleAccess: %v", err) + } + if !console { + t.Error("withdrawing an application grant must not cost the administrator their console") + } +} diff --git a/internal/store/installation.go b/internal/store/installation.go index 5fc903f..8e4f989 100644 --- a/internal/store/installation.go +++ b/internal/store/installation.go @@ -98,6 +98,13 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser return tx.Commit() } +// ClaimFirstSystemAdministrator promotes the first user to sign in to system +// administrator and gives them ownership of every tenant that exists at that +// moment. It is a no-op once an administrator exists. +// +// It grants console reach across the installation and nothing more: no +// application grant, so the new administrator cannot sign in to a tenant +// application until one is deliberately given to them. func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string) (bool, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -124,11 +131,14 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string ON CONFLICT DO NOTHING`, userID); err != nil { return false, err } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO user_application_access(application_id, user_id, granted_by) - SELECT a.id, $1, $1 FROM applications a ON CONFLICT DO NOTHING`, userID); err != nil { - return false, err - } + // Deliberately no application grant. Owning every tenant is what lets the + // administrator administer it; it is not a licence to enter the applications + // running inside it. Access to a client application is only ever an explicit + // grant, so an administrator who wants to use one attaches themselves the same + // way anybody else does, visibly, from the console, and it can be withdrawn + // again without touching the tenant. Granting it here instead would hand every + // installation a standing administrator account inside every customer + // application, which is the opposite of the boundary this product draws. return true, tx.Commit() } diff --git a/web/openapi.json b/web/openapi.json index 1b4655d..e0ff183 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -5,14 +5,7 @@ "version": "0.1.0", "description": "Multi-tenant API-key management and runtime authorization." }, - "servers": [{"url": "/"}], "components": { - "securitySchemes": { - "session": {"type": "apiKey", "in": "cookie", "name": "go_loose_session"}, - "apiKey": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, - "clientBasic": {"type": "http", "scheme": "basic"}, - "userBearer": {"type": "http", "scheme": "bearer"} - }, "schemas": { "Error": {"type": "object", "properties": {"error": {"type": "string"}}}, "AuthorizationRequest": { diff --git a/web/style.css b/web/style.css index a21a772..41c21f1 100644 --- a/web/style.css +++ b/web/style.css @@ -1,11 +1,11 @@ -:root{--bg:#16161e;--surface:#1f2335;--surface2:#24283b;--text:#c0caf5;--muted:#7aa2f7;--accent:#7dcfff;--accent2:#bb9af7;--good:#9ece6a;--danger:#f7768e;--border:#3b4261;--shadow:rgba(0,0,0,.35)} -[data-theme=catppuccin]{--bg:#1e1e2e;--surface:#181825;--surface2:#313244;--text:#cdd6f4;--muted:#89b4fa;--accent:#94e2d5;--accent2:#cba6f7;--good:#a6e3a1;--danger:#f38ba8;--border:#45475a} -[data-theme=tomorrow]{--bg:#1d1f21;--surface:#282a2e;--surface2:#373b41;--text:#c5c8c6;--muted:#81a2be;--accent:#8abeb7;--accent2:#b294bb;--good:#b5bd68;--danger:#cc6666;--border:#4d5057} -[data-theme=kanagawa]{--bg:#1f1f28;--surface:#2a2a37;--surface2:#363646;--text:#dcd7ba;--muted:#7e9cd8;--accent:#7fb4ca;--accent2:#957fb8;--good:#98bb6c;--danger:#e46876;--border:#54546d} -[data-mode=light][data-theme=tokyo]{--bg:#e6e7ed;--surface:#fff;--surface2:#dfe2ed;--text:#343b58;--muted:#34548a;--accent:#0f4b6e;--accent2:#5a4a78;--good:#485e30;--danger:#8c4351;--border:#b4b5c3;--shadow:rgba(52,59,88,.15)} -[data-mode=light][data-theme=catppuccin]{--bg:#eff1f5;--surface:#fff;--surface2:#e6e9ef;--text:#4c4f69;--muted:#1e66f5;--accent:#179299;--accent2:#8839ef;--good:#40a02b;--danger:#d20f39;--border:#bcc0cc;--shadow:rgba(76,79,105,.15)} -[data-mode=light][data-theme=tomorrow]{--bg:#f5f5f5;--surface:#fff;--surface2:#e8e8e8;--text:#4d4d4c;--muted:#4271ae;--accent:#3e999f;--accent2:#8959a8;--good:#718c00;--danger:#c82829;--border:#c9c9c9;--shadow:rgba(77,77,76,.15)} -[data-mode=light][data-theme=kanagawa]{--bg:#f2ecde;--surface:#fffaf0;--surface2:#e7dba9;--text:#545464;--muted:#4d699b;--accent:#4d7f8c;--accent2:#624c83;--good:#6f894e;--danger:#c34043;--border:#c8c093;--shadow:rgba(84,84,100,.15)} +:root{--bg:var(--bn-bg,#16161e);--surface:var(--bn-surface,#1f2335);--surface2:var(--bn-surface-raised,#24283b);--text:var(--bn-text,#c0caf5);--muted:var(--bn-muted,#7aa2f7);--accent:var(--bn-accent,#7dcfff);--accent2:var(--bn-secondary,#bb9af7);--good:var(--bn-good,#9ece6a);--danger:var(--bn-danger,#f7768e);--border:var(--bn-border,#3b4261);--shadow:rgba(0,0,0,.35)} +[data-theme=catppuccin]{--bg:var(--bn-bg,#1e1e2e);--surface:var(--bn-surface,#181825);--surface2:var(--bn-surface-raised,#313244);--text:var(--bn-text,#cdd6f4);--muted:var(--bn-muted,#89b4fa);--accent:var(--bn-accent,#94e2d5);--accent2:var(--bn-secondary,#cba6f7);--good:var(--bn-good,#a6e3a1);--danger:var(--bn-danger,#f38ba8);--border:var(--bn-border,#45475a)} +[data-theme=tomorrow]{--bg:var(--bn-bg,#1d1f21);--surface:var(--bn-surface,#282a2e);--surface2:var(--bn-surface-raised,#373b41);--text:var(--bn-text,#c5c8c6);--muted:var(--bn-muted,#81a2be);--accent:var(--bn-accent,#8abeb7);--accent2:var(--bn-secondary,#b294bb);--good:var(--bn-good,#b5bd68);--danger:var(--bn-danger,#cc6666);--border:var(--bn-border,#4d5057)} +[data-theme=kanagawa]{--bg:var(--bn-bg,#1f1f28);--surface:var(--bn-surface,#2a2a37);--surface2:var(--bn-surface-raised,#363646);--text:var(--bn-text,#dcd7ba);--muted:var(--bn-muted,#7e9cd8);--accent:var(--bn-accent,#7fb4ca);--accent2:var(--bn-secondary,#957fb8);--good:var(--bn-good,#98bb6c);--danger:var(--bn-danger,#e46876);--border:var(--bn-border,#54546d)} +[data-mode=light][data-theme=tokyo]{--bg:var(--bn-bg,#e6e7ed);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#dfe2ed);--text:var(--bn-text,#343b58);--muted:var(--bn-muted,#34548a);--accent:var(--bn-accent,#0f4b6e);--accent2:var(--bn-secondary,#5a4a78);--good:var(--bn-good,#485e30);--danger:var(--bn-danger,#8c4351);--border:var(--bn-border,#b4b5c3);--shadow:rgba(52,59,88,.15)} +[data-mode=light][data-theme=catppuccin]{--bg:var(--bn-bg,#eff1f5);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#e6e9ef);--text:var(--bn-text,#4c4f69);--muted:var(--bn-muted,#1e66f5);--accent:var(--bn-accent,#179299);--accent2:var(--bn-secondary,#8839ef);--good:var(--bn-good,#40a02b);--danger:var(--bn-danger,#d20f39);--border:var(--bn-border,#bcc0cc);--shadow:rgba(76,79,105,.15)} +[data-mode=light][data-theme=tomorrow]{--bg:var(--bn-bg,#f5f5f5);--surface:var(--bn-surface,#fff);--surface2:var(--bn-surface-raised,#e8e8e8);--text:var(--bn-text,#4d4d4c);--muted:var(--bn-muted,#4271ae);--accent:var(--bn-accent,#3e999f);--accent2:var(--bn-secondary,#8959a8);--good:var(--bn-good,#718c00);--danger:var(--bn-danger,#c82829);--border:var(--bn-border,#c9c9c9);--shadow:rgba(77,77,76,.15)} +[data-mode=light][data-theme=kanagawa]{--bg:var(--bn-bg,#f2ecde);--surface:var(--bn-surface,#fffaf0);--surface2:var(--bn-surface-raised,#e7dba9);--text:var(--bn-text,#545464);--muted:var(--bn-muted,#4d699b);--accent:var(--bn-accent,#4d7f8c);--accent2:var(--bn-secondary,#624c83);--good:var(--bn-good,#6f894e);--danger:var(--bn-danger,#c34043);--border:var(--bn-border,#c8c093);--shadow:rgba(84,84,100,.15)} *{box-sizing:border-box}html{background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0;min-height:100vh;background:radial-gradient(circle at 70% -10%,color-mix(in srgb,var(--accent2) 13%,transparent),transparent 35%),var(--bg)}button,input,textarea,select{font:inherit}.noise{position:fixed;inset:0;pointer-events:none;opacity:.025;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")} .docs-page{background-color: white}header{height:72px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 max(24px,5vw);background:color-mix(in srgb,var(--bg) 85%,transparent);backdrop-filter:blur(16px);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-weight:750;text-decoration:none;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid var(--accent);color:var(--accent);font:700 12px ui-monospace,monospace;clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%)}nav{display:flex;align-items:center;gap:18px}nav a{color:var(--muted);text-decoration:none;font-size:14px}nav form{margin:0}.compact{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}select,input,textarea{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:7px;padding:10px 12px;outline:none}select:focus,input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent)}.compact select{padding:6px}.icon-button,.ghost{padding:7px 10px;background:transparent;border:1px solid var(--border);color:var(--text);border-radius:7px}.identity{font-size:13px;color:var(--accent)} main{width:min(1400px,92vw);margin:0 auto 80px}.hero{min-height:330px;display:flex;align-items:center;justify-content:space-between;gap:60px;border-bottom:1px solid var(--border)}.eyebrow{font:700 11px ui-monospace,monospace;letter-spacing:.16em;color:var(--accent);margin:0 0 12px}.hero h1{font-size:clamp(44px,5vw,74px);line-height:.94;letter-spacing:-.055em;margin:0}.hero h1 span{color:var(--accent2)}.lede{max-width:680px;color:color-mix(in srgb,var(--text) 70%,transparent);font-size:17px;line-height:1.7}.status-card{min-width:320px;background:linear-gradient(145deg,var(--surface),var(--surface2));border:1px solid var(--border);border-radius:14px;padding:22px;box-shadow:0 20px 60px var(--shadow);display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:center}.status-card span{display:block;color:var(--good);font-size:12px;margin-top:4px}.status-card code{grid-column:1/-1;background:var(--bg);padding:12px;border-radius:7px;color:var(--muted)}.pulse{width:10px;height:10px;border-radius:50%;background:var(--good);box-shadow:0 0 0 6px color-mix(in srgb,var(--good) 14%,transparent)}