carlos / platform Public

feat: platform console slice 1 — sign up/in with Keymail, read-only apps dashboard

OAuth 2.1 PKCE against keymail.dev (one-time ceremony, scope
identify send, ECDSA P-256 signing key per Keymail's contract);
returning sign-ins deliver sealed single-use confirmation links into
the user's Keymail Apps inbox. Owners-gated read-model over the
registry and release store: routes, instance counts, versions,
channels with approximate bake windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 492ebf661f204fc02d0581f194b9cb79176e5b6c parent a91a7ae
15 files changed, +2785 −0
  • cmd/console/main.go +134 −0
  • internal/console/keymail.go +349 −0
  • internal/console/keymail_test.go +407 −0
  • internal/console/server.go +446 −0
  • internal/console/server_test.go +332 −0
  • internal/console/store.go +370 −0
  • internal/console/store_test.go +228 −0
  • internal/console/templates/app.html +44 −0
  • internal/console/templates/apps.html +25 −0
  • internal/console/templates/check.html +6 −0
  • internal/console/templates/home.html +10 −0
  • internal/console/templates/layout.html +107 −0
  • internal/console/templates/message.html +5 −0
  • internal/console/view.go +145 −0
  • internal/console/view_test.go +177 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
new file mode 100644
index 000000000..c009273ba
--- /dev/null
+++ b/cmd/console/main.go
@@ -0,0 +1,134 @@
+// Command console is the CARLOS platform console — the app behind
+// https://platform.carlosframework.com. Sign up / sign in with Keymail, see
+// your apps and their live platform state. Read-only against platform state
+// in this slice.
+//
+// Verbs:
+//
+// console serve run the HTTP server
+// console grant <address> <app> make address an owner of app
+// console revoke <address> <app> remove that ownership
+// console owners list every owner and their apps
+//
+// serve reads: CONSOLE_ADDR (default 127.0.0.1:8090), CONSOLE_DB (default
+// /data/console/console.db), CONSOLE_ORIGIN (required, e.g.
+// https://platform.carlosframework.com), CONSOLE_KEYMAIL (default
+// https://keymail.dev), CARLOS_REGISTRY (default /data/registry.db),
+// CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY.
+// grant/revoke/owners need only CONSOLE_DB.
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func main() {
+ log.SetFlags(0)
+ if len(os.Args) < 2 {
+ usage()
+ }
+ switch os.Args[1] {
+ case "serve":
+ serve()
+ case "grant", "revoke":
+ if len(os.Args) != 4 {
+ log.Fatalf("usage: console %s <address> <app>", os.Args[1])
+ }
+ st := openConsoleDB()
+ var err error
+ if os.Args[1] == "grant" {
+ err = st.Grant(os.Args[2], os.Args[3])
+ } else {
+ err = st.Revoke(os.Args[2], os.Args[3])
+ }
+ if err != nil {
+ log.Fatal(err)
+ }
+ case "owners":
+ st := openConsoleDB()
+ owners, err := st.Owners()
+ if err != nil {
+ log.Fatal(err)
+ }
+ addrs := make([]string, 0, len(owners))
+ for a := range owners {
+ addrs = append(addrs, a)
+ }
+ sort.Strings(addrs)
+ for _, a := range addrs {
+ fmt.Printf("%s\t%s\n", a, strings.Join(owners[a], " "))
+ }
+ default:
+ usage()
+ }
+}
+
+func usage() {
+ log.Fatal("usage: console serve | grant <address> <app> | revoke <address> <app> | owners")
+}
+
+func serve() {
+ origin := strings.TrimSuffix(os.Getenv("CONSOLE_ORIGIN"), "/")
+ if origin == "" {
+ log.Fatal("set CONSOLE_ORIGIN (e.g. https://platform.carlosframework.com)")
+ }
+ pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ log.Fatal(err)
+ }
+ st := openConsoleDB()
+ srv, err := console.NewServer(console.Config{
+ Origin: origin,
+ Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"),
+ Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"),
+ Releases: openStore(),
+ ReleasePub: pub,
+ }, st)
+ if err != nil {
+ log.Fatal(err)
+ }
+ addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090")
+ log.Printf("console: %s listening on %s", origin, addr)
+ log.Fatal(http.ListenAndServe(addr, srv))
+}
+
+func openConsoleDB() *console.Store {
+ st, err := console.Open(envOr("CONSOLE_DB", "/data/console/console.db"))
+ if err != nil {
+ log.Fatal(err)
+ }
+ return st
+}
+
+// openStore mirrors the carlos CLI's ~10-line store-open helper (cli.go);
+// duplicated here because cmd/console cannot import package main.
+func openStore() release.Store {
+ if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" {
+ s, err := release.NewS3Store(context.Background(), b)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return s
+ }
+ if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" {
+ return release.NewDirStore(d)
+ }
+ log.Fatal("set CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (local)")
+ return nil
+}
+
+func envOr(k, def string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return def
+}
diff --git a/internal/console/keymail.go b/internal/console/keymail.go
new file mode 100644
index 000000000..5e1812ce3
--- /dev/null
+++ b/internal/console/keymail.go
@@ -0,0 +1,349 @@
+// Sign in with Keymail — a hand-written client for keymail's JSON-bodied
+// OAuth 2.1 (authorization code + PKCE S256, no client registration:
+// client_id IS the console's origin) and its app API (sealed sends into the
+// user's Apps inbox).
+//
+// Wire contract implemented from the keymail source of truth:
+// keymail oauth.go (handleOAuthToken, handleOAuthRevoke), appapi.go
+// (handleAppSend), federation.go (envelope), crypto.go (seal / sign /
+// key encodings), docs/apps.md. All bodies are JSON, never form-encoded —
+// off-the-shelf oauth2 libraries do not speak this, hence by hand.
+package console
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/ecdh"
+ "crypto/ecdsa"
+ "crypto/hkdf"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// ErrGrantRevoked reports a send refused with 401: the user revoked the
+// console's grant in Keymail (or the token is otherwise dead), so the only
+// road back is the OAuth ceremony, which rotates the grant.
+var ErrGrantRevoked = errors.New("keymail: grant revoked or invalid")
+
+// Keymail is a client bound to one keymail base URL and one console origin.
+type Keymail struct {
+ base string // keymail base URL, no trailing slash
+ origin string // the console's origin — its OAuth client_id
+ signKey *ecdsa.PrivateKey
+ httpc *http.Client
+}
+
+// NewKeymail builds a client. origin is the console's https origin (its
+// client_id); baseURL the keymail deployment (https://keymail.dev).
+func NewKeymail(baseURL, origin string) *Keymail {
+ return &Keymail{
+ base: strings.TrimSuffix(baseURL, "/"),
+ origin: strings.TrimSuffix(origin, "/"),
+ httpc: &http.Client{Timeout: 15 * time.Second},
+ }
+}
+
+// SetSigningKey installs the console's long-lived envelope-signing key
+// (ECDSA P-256). With a key set the client asks for scope "identify send"
+// and pins the key into the grant at exchange (keymail requires sign_pub
+// whenever the grant can deliver mail); without one it asks for bare
+// "identify" and cannot SendApp.
+func (k *Keymail) SetSigningKey(priv *ecdsa.PrivateKey) { k.signKey = priv }
+
+// SignPub is the base64 raw uncompressed P-256 point keymail pins in the
+// grant ("" when no key is set).
+func (k *Keymail) SignPub() string {
+ if k.signKey == nil {
+ return ""
+ }
+ pub, err := k.signKey.PublicKey.ECDH()
+ if err != nil {
+ return ""
+ }
+ return base64.StdEncoding.EncodeToString(pub.Bytes())
+}
+
+func (k *Keymail) scope() string {
+ if k.signKey != nil {
+ return "identify send"
+ }
+ return "identify"
+}
+
+// redirectURI is fixed by the console's route table.
+func (k *Keymail) redirectURI() string { return k.origin + "/auth/callback" }
+
+// AuthorizeURL is where the browser goes to approve the console.
+func (k *Keymail) AuthorizeURL(state, challenge string) string {
+ q := url.Values{
+ "client_id": {k.origin},
+ "redirect_uri": {k.redirectURI()},
+ "scope": {k.scope()},
+ "state": {state},
+ "code_challenge": {challenge},
+ "code_challenge_method": {"S256"},
+ }
+ return k.base + "/oauth/authorize?" + q.Encode()
+}
+
+// Exchange trades the callback's code (plus the PKCE verifier) for the
+// user's address and the console's access token, server-side.
+func (k *Keymail) Exchange(ctx context.Context, code, verifier string) (address, token string, err error) {
+ req := map[string]string{
+ "code": code,
+ "code_verifier": verifier,
+ "client_id": k.origin,
+ "redirect_uri": k.redirectURI(),
+ }
+ if sp := k.SignPub(); sp != "" {
+ req["sign_pub"] = sp
+ }
+ var out struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ Scope string `json:"scope"`
+ Address string `json:"address"`
+ }
+ if _, err := k.do(ctx, http.MethodPost, "/api/oauth/token", "", req, &out); err != nil {
+ return "", "", err
+ }
+ if !strings.EqualFold(out.TokenType, "bearer") {
+ return "", "", fmt.Errorf("keymail: token exchange returned token_type %q, want bearer", out.TokenType)
+ }
+ if out.AccessToken == "" || out.Address == "" {
+ return "", "", errors.New("keymail: token exchange returned an empty token or address")
+ }
+ return out.Address, out.AccessToken, nil
+}
+
+// Revoke retires an access token (RFC 7009 manners server-side: always 200,
+// revealing nothing). Callers treat failures as best-effort.
+func (k *Keymail) Revoke(ctx context.Context, token string) error {
+ _, err := k.do(ctx, http.MethodPost, "/api/oauth/revoke", "", map[string]string{"token": token}, nil)
+ return err
+}
+
+// ---- app send (the returning-sign-in confirmation channel) ------------------
+
+// envelope is keymail's message-in-flight shape (federation.go): plaintext
+// routing headers, subject+body sealed to the recipient's box key in blob,
+// sig the sender's ECDSA signature over headers+blob.
+type envelope struct {
+ V string `json:"v"`
+ ID string `json:"id"`
+ From string `json:"from"`
+ To string `json:"to"`
+ Date string `json:"date"`
+ SignPub string `json:"sign_pub"`
+ Blob string `json:"blob"`
+ Sig string `json:"sig"`
+}
+
+const (
+ protocolVersion = "keymail1"
+ envelopeInfo = "keymail-envelope-v1"
+ envSigContext = "keymail-env-v1"
+)
+
+// SendApp delivers one sealed message (kind "app" → the user's Apps inbox)
+// to the granting user, using the stored grant token. It looks up the
+// recipient's box key, seals {subject, body} to it, signs the envelope with
+// the console's pinned key, and posts it. Returns the envelope id (the
+// sender mints it, so a deep link is knowable up front). A 401 comes back
+// as ErrGrantRevoked.
+func (k *Keymail) SendApp(ctx context.Context, token, to, subject, body string) (msgID string, err error) {
+ if k.signKey == nil {
+ return "", errors.New("keymail: SendApp requires a signing key")
+ }
+ // From is pinned server-side to the console's own origin host — the
+ // console writes as itself, never as anyone the user might trust.
+ from := "console@" + OriginHost(k.origin)
+ var lr struct {
+ BoxPub string `json:"box_pub"`
+ }
+ lu := "/api/federation/lookup?addr=" + url.QueryEscape(to) + "&from=" + url.QueryEscape(from)
+ if _, err := k.do(ctx, http.MethodGet, lu, "", nil, &lr); err != nil {
+ return "", fmt.Errorf("keymail: lookup %s: %w", to, err)
+ }
+ payload, err := json.Marshal(map[string]string{"subject": subject, "body": body})
+ if err != nil {
+ return "", err
+ }
+ blob, err := sealToBox(lr.BoxPub, payload)
+ if err != nil {
+ return "", fmt.Errorf("keymail: seal to %s: %w", to, err)
+ }
+ idb := make([]byte, 16)
+ if _, err := rand.Read(idb); err != nil {
+ return "", err
+ }
+ id := hex.EncodeToString(idb)
+ date := time.Now().UTC().Format(time.RFC3339)
+ sig, err := signRaw(k.signKey, envelopeMsg(id, from, to, date, blob))
+ if err != nil {
+ return "", err
+ }
+ env := envelope{
+ V: protocolVersion, ID: id, From: from, To: to, Date: date,
+ SignPub: k.SignPub(), Blob: blob, Sig: sig,
+ }
+ status, err := k.do(ctx, http.MethodPost, "/api/app/send", token,
+ map[string]any{"envelope": env, "kind": "app"}, nil)
+ if status == http.StatusUnauthorized {
+ return "", ErrGrantRevoked
+ }
+ if err != nil {
+ return "", err
+ }
+ return id, nil
+}
+
+// OriginHost strips the scheme off an origin: the host (with port, if any)
+// that keymail treats as the app's identity.
+func OriginHost(origin string) string {
+ h := strings.TrimPrefix(origin, "https://")
+ return strings.TrimPrefix(h, "http://")
+}
+
+// ---- HTTP plumbing ----------------------------------------------------------
+
+// do runs one JSON round-trip. Non-200 answers become an error carrying the
+// server's {"error": ...} text; the status is returned either way so callers
+// can classify (401 → revoked grant).
+func (k *Keymail) do(ctx context.Context, method, path, bearer string, in, out any) (int, error) {
+ var body io.Reader
+ if in != nil {
+ raw, err := json.Marshal(in)
+ if err != nil {
+ return 0, err
+ }
+ body = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, k.base+path, body)
+ if err != nil {
+ return 0, err
+ }
+ if in != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := k.httpc.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return res.StatusCode, err
+ }
+ if res.StatusCode != http.StatusOK {
+ var e struct {
+ Error string `json:"error"`
+ }
+ msg := res.Status
+ if json.Unmarshal(raw, &e) == nil && e.Error != "" {
+ msg = e.Error
+ }
+ return res.StatusCode, fmt.Errorf("keymail: %s %s: %s", method, path, msg)
+ }
+ if out != nil {
+ if err := json.Unmarshal(raw, out); err != nil {
+ return res.StatusCode, fmt.Errorf("keymail: %s %s: bad response body: %v", method, path, err)
+ }
+ }
+ return res.StatusCode, nil
+}
+
+// ---- PKCE -------------------------------------------------------------------
+
+// NewVerifier mints a PKCE code verifier (43 base64url chars — inside the
+// RFC 7636 window). Also serves as the state generator.
+func NewVerifier() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// ChallengeS256 derives the S256 code challenge for a verifier.
+func ChallengeS256(verifier string) string {
+ sum := sha256.Sum256([]byte(verifier))
+ return base64.RawURLEncoding.EncodeToString(sum[:])
+}
+
+// ---- envelope crypto (ported from keymail crypto.go, byte-compatible) -------
+
+// envelopeMsg is what a sender signs per message: routing headers and the
+// ciphertext, so tampering with any of them breaks the signature.
+func envelopeMsg(id, from, to, date, blob string) []byte {
+ return []byte(envSigContext + "\n" + id + "\n" + from + "\n" + to + "\n" + date + "\n" + blob)
+}
+
+// signRaw produces a WebCrypto-compatible raw r||s ECDSA P-256 signature.
+func signRaw(priv *ecdsa.PrivateKey, msg []byte) (string, error) {
+ h := sha256.Sum256(msg)
+ r, s, err := ecdsa.Sign(rand.Reader, priv, h[:])
+ if err != nil {
+ return "", err
+ }
+ sig := make([]byte, 64)
+ r.FillBytes(sig[:32])
+ s.FillBytes(sig[32:])
+ return base64.StdEncoding.EncodeToString(sig), nil
+}
+
+// sealToBox encrypts payload to a recipient box key: ephemeral ECDH P-256 →
+// HKDF-SHA256("keymail-envelope-v1") → AES-GCM-256, blob = ephPub(65) ||
+// iv(12) || ciphertext, base64 (std).
+func sealToBox(recipientBoxPubB64 string, payload []byte) (string, error) {
+ raw, err := base64.StdEncoding.DecodeString(recipientBoxPubB64)
+ if err != nil {
+ return "", errors.New("bad recipient key encoding")
+ }
+ pub, err := ecdh.P256().NewPublicKey(raw)
+ if err != nil {
+ return "", fmt.Errorf("bad recipient key: %w", err)
+ }
+ eph, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ return "", err
+ }
+ shared, err := eph.ECDH(pub)
+ if err != nil {
+ return "", err
+ }
+ key, err := hkdf.Key(sha256.New, shared, nil, envelopeInfo, 32)
+ if err != nil {
+ return "", err
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return "", err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+ iv := make([]byte, 12)
+ if _, err := rand.Read(iv); err != nil {
+ return "", err
+ }
+ ct := gcm.Seal(nil, iv, payload, nil)
+ blob := append(append(eph.PublicKey().Bytes(), iv...), ct...)
+ return base64.StdEncoding.EncodeToString(blob), nil
+}
diff --git a/internal/console/keymail_test.go b/internal/console/keymail_test.go
new file mode 100644
index 000000000..f2dcfb020
--- /dev/null
+++ b/internal/console/keymail_test.go
@@ -0,0 +1,407 @@
+package console
+
+import (
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/ecdh"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/hkdf"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "math/big"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// ---- a fake keymail, faithful to oauth.go / appapi.go on the wires the
+// console touches. Shared with server_test.go.
+
+type sentMessage struct {
+ ID, From, To, Subject, Body, Kind string
+}
+
+type fakeKeymail struct {
+ t *testing.T
+ srv *httptest.Server
+
+ mu sync.Mutex
+ address string // the user identity minted at exchange
+ expectCode string // the single code the fake will honor
+ challenge string // PKCE challenge captured from the authorize URL
+ token string // access token minted at exchange
+ signPub string // sign_pub pinned at exchange
+ origin string // client_id seen at exchange
+ boxPriv *ecdh.PrivateKey
+ send401 bool // simulate a revoked grant on /api/app/send
+ revoked []string
+ sends []sentMessage
+ exchanges int
+}
+
+func newFakeKeymail(t *testing.T, address string) *fakeKeymail {
+ t.Helper()
+ boxPriv, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f := &fakeKeymail{t: t, address: address, boxPriv: boxPriv}
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/oauth/token", f.handleToken)
+ mux.HandleFunc("POST /api/oauth/revoke", f.handleRevoke)
+ mux.HandleFunc("GET /api/federation/lookup", f.handleLookup)
+ mux.HandleFunc("POST /api/app/send", f.handleSend)
+ f.srv = httptest.NewServer(mux)
+ t.Cleanup(f.srv.Close)
+ return f
+}
+
+func jsonErr(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
+
+func (f *fakeKeymail) handleToken(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var req struct {
+ Code string `json:"code"`
+ CodeVerifier string `json:"code_verifier"`
+ ClientID string `json:"client_id"`
+ RedirectURI string `json:"redirect_uri"`
+ SignPub string `json:"sign_pub"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonErr(w, http.StatusBadRequest, "bad json")
+ return
+ }
+ if f.expectCode == "" || req.Code != f.expectCode {
+ jsonErr(w, http.StatusBadRequest, "code invalid, used, or expired")
+ return
+ }
+ f.expectCode = "" // single-use, burn-then-check manners
+ if f.challenge != "" && ChallengeS256(req.CodeVerifier) != f.challenge {
+ jsonErr(w, http.StatusBadRequest, "PKCE verification failed")
+ return
+ }
+ if req.RedirectURI != req.ClientID+"/auth/callback" {
+ jsonErr(w, http.StatusBadRequest, "client_id or redirect_uri does not match the authorization")
+ return
+ }
+ // Scope "identify send" requires the app's envelope-signing key: a
+ // base64 raw uncompressed P-256 point (keymail parseECDSAPub).
+ raw, err := base64.StdEncoding.DecodeString(req.SignPub)
+ if err != nil || len(raw) != 65 || raw[0] != 4 {
+ jsonErr(w, http.StatusBadRequest, "sign_pub (the app's envelope-signing key) is required to send")
+ return
+ }
+ f.signPub = req.SignPub
+ f.origin = req.ClientID
+ f.token = "user.tok-" + f.address
+ f.exchanges++
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{
+ "access_token": f.token,
+ "token_type": "bearer",
+ "scope": "identify send",
+ "address": f.address,
+ })
+}
+
+func (f *fakeKeymail) handleRevoke(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var req struct {
+ Token string `json:"token"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ f.revoked = append(f.revoked, req.Token)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
+}
+
+func (f *fakeKeymail) handleLookup(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "addr": r.URL.Query().Get("addr"),
+ "sign_pub": "",
+ "box_pub": base64.StdEncoding.EncodeToString(f.boxPriv.PublicKey().Bytes()),
+ "pow_bits": 8,
+ })
+}
+
+func (f *fakeKeymail) handleSend(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.send401 || r.Header.Get("Authorization") != "Bearer "+f.token || f.token == "" {
+ jsonErr(w, http.StatusUnauthorized, "bad, expired or revoked app token")
+ return
+ }
+ var req struct {
+ Envelope envelope `json:"envelope"`
+ Kind string `json:"kind"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonErr(w, http.StatusBadRequest, "bad json")
+ return
+ }
+ e := req.Envelope
+ if e.To != f.address {
+ jsonErr(w, http.StatusForbidden, "envelope to must be "+f.address)
+ return
+ }
+ if host := OriginHost(f.origin); !strings.HasSuffix(e.From, "@"+host) {
+ jsonErr(w, http.StatusForbidden, "envelope from must be an address on "+host)
+ return
+ }
+ if e.SignPub != f.signPub {
+ jsonErr(w, http.StatusForbidden, "signing key does not match this grant")
+ return
+ }
+ if e.V != "keymail1" || e.ID == "" || e.Date == "" ||
+ !testVerify(e.SignPub, envelopeMsg(e.ID, e.From, e.To, e.Date, e.Blob), e.Sig) {
+ jsonErr(w, http.StatusBadRequest, "bad envelope signature")
+ return
+ }
+ plain, err := testOpen(f.boxPriv, e.Blob)
+ if err != nil {
+ jsonErr(w, http.StatusBadRequest, "blob does not open: "+err.Error())
+ return
+ }
+ var payload struct{ Subject, Body string }
+ if err := json.Unmarshal(plain, &payload); err != nil {
+ jsonErr(w, http.StatusBadRequest, "payload is not subject/body json")
+ return
+ }
+ f.sends = append(f.sends, sentMessage{
+ ID: e.ID, From: e.From, To: e.To,
+ Subject: payload.Subject, Body: payload.Body, Kind: req.Kind,
+ })
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "delivered", "folder": "apps"})
+}
+
+// testVerify mirrors keymail crypto.go verifySig: raw r||s ECDSA P-256 over
+// sha256(msg), key a base64 raw uncompressed point.
+func testVerify(b64pub string, msg []byte, b64sig string) bool {
+ raw, err := base64.StdEncoding.DecodeString(b64pub)
+ if err != nil || len(raw) != 65 || raw[0] != 4 {
+ return false
+ }
+ pub := &ecdsa.PublicKey{
+ Curve: elliptic.P256(),
+ X: new(big.Int).SetBytes(raw[1:33]),
+ Y: new(big.Int).SetBytes(raw[33:65]),
+ }
+ sig, err := base64.StdEncoding.DecodeString(b64sig)
+ if err != nil || len(sig) != 64 {
+ return false
+ }
+ h := sha256.Sum256(msg)
+ return ecdsa.Verify(pub, h[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:]))
+}
+
+// testOpen mirrors keymail crypto.go open: the inverse of sealToBox.
+func testOpen(boxPriv *ecdh.PrivateKey, blobB64 string) ([]byte, error) {
+ blob, err := base64.StdEncoding.DecodeString(blobB64)
+ if err != nil || len(blob) < 65+12+16 {
+ return nil, errors.New("bad blob")
+ }
+ ephPub, err := ecdh.P256().NewPublicKey(blob[:65])
+ if err != nil {
+ return nil, err
+ }
+ shared, err := boxPriv.ECDH(ephPub)
+ if err != nil {
+ return nil, err
+ }
+ key, err := hkdf.Key(sha256.New, shared, nil, envelopeInfo, 32)
+ if err != nil {
+ return nil, err
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ return gcm.Open(nil, blob[65:77], blob[77:], nil)
+}
+
+func testSigningKey(t *testing.T) *ecdsa.PrivateKey {
+ t.Helper()
+ k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return k
+}
+
+// ---- tests ------------------------------------------------------------------
+
+func TestAuthorizeURL(t *testing.T) {
+ km := NewKeymail("https://keymail.dev/", "https://platform.carlosframework.com")
+ km.SetSigningKey(testSigningKey(t))
+ u, err := url.Parse(km.AuthorizeURL("state123", "chal456"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.Scheme != "https" || u.Host != "keymail.dev" || u.Path != "/oauth/authorize" {
+ t.Fatalf("authorize URL = %v", u)
+ }
+ q := u.Query()
+ for k, want := range map[string]string{
+ "client_id": "https://platform.carlosframework.com",
+ "redirect_uri": "https://platform.carlosframework.com/auth/callback",
+ "scope": "identify send",
+ "state": "state123",
+ "code_challenge": "chal456",
+ "code_challenge_method": "S256",
+ } {
+ if got := q.Get(k); got != want {
+ t.Errorf("%s = %q, want %q", k, got, want)
+ }
+ }
+}
+
+func TestAuthorizeURLScopeWithoutKey(t *testing.T) {
+ km := NewKeymail("https://keymail.dev", "https://platform.carlosframework.com")
+ u, _ := url.Parse(km.AuthorizeURL("s", "c"))
+ if got := u.Query().Get("scope"); got != "identify" {
+ t.Fatalf("scope without signing key = %q, want identify", got)
+ }
+}
+
+func TestExchangeRoundTrip(t *testing.T) {
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com")
+ km.SetSigningKey(testSigningKey(t))
+
+ verifier, err := NewVerifier()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := len(verifier); n < 43 || n > 128 {
+ t.Fatalf("verifier length %d outside RFC 7636 window", n)
+ }
+ fk.challenge = ChallengeS256(verifier)
+ fk.expectCode = "code-1"
+ addr, token, err := km.Exchange(context.Background(), "code-1", verifier)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if addr != "paul@keymail.dev" {
+ t.Fatalf("address = %q", addr)
+ }
+ if token == "" || token != fk.token {
+ t.Fatalf("token = %q, fake minted %q", token, fk.token)
+ }
+ if fk.signPub != km.SignPub() {
+ t.Fatalf("fake pinned sign_pub %q, client's is %q", fk.signPub, km.SignPub())
+ }
+}
+
+func TestExchangePKCEMismatch(t *testing.T) {
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com")
+ km.SetSigningKey(testSigningKey(t))
+ fk.challenge = ChallengeS256("the-real-verifier")
+ fk.expectCode = "code-1"
+ _, _, err := km.Exchange(context.Background(), "code-1", "a-different-verifier")
+ if err == nil || !strings.Contains(err.Error(), "PKCE") {
+ t.Fatalf("err = %v, want PKCE failure surfaced", err)
+ }
+}
+
+func TestExchangeBadCode(t *testing.T) {
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com")
+ km.SetSigningKey(testSigningKey(t))
+ fk.expectCode = "code-1"
+ _, _, err := km.Exchange(context.Background(), "guessed", "v")
+ if err == nil || !strings.Contains(err.Error(), "code invalid") {
+ t.Fatalf("err = %v, want code-invalid error", err)
+ }
+}
+
+func TestRevoke(t *testing.T) {
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ km := NewKeymail(fk.srv.URL, "https://platform.carlosframework.com")
+ if err := km.Revoke(context.Background(), "user.tok"); err != nil {
+ t.Fatal(err)
+ }
+ if len(fk.revoked) != 1 || fk.revoked[0] != "user.tok" {
+ t.Fatalf("revoked = %v", fk.revoked)
+ }
+}
+
+func TestSendAppSealedRoundTrip(t *testing.T) {
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ km := NewKeymail(fk.srv.URL, "http://127.0.0.1:8090")
+ km.SetSigningKey(testSigningKey(t))
+ verifier, _ := NewVerifier()
+ fk.challenge = ChallengeS256(verifier)
+ fk.expectCode = "code-1"
+ _, token, err := km.Exchange(context.Background(), "code-1", verifier)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := km.SendApp(context.Background(), token, "paul@keymail.dev",
+ "Sign in", "click https://example.com/auth/confirm?token=abc")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id == "" {
+ t.Fatal("no envelope id")
+ }
+ if len(fk.sends) != 1 {
+ t.Fatalf("fake got %d sends", len(fk.sends))
+ }
+ got := fk.sends[0]
+ if got.ID != id || got.To != "paul@keymail.dev" || got.From != "console@127.0.0.1:8090" {
+ t.Fatalf("send = %+v", got)
+ }
+ if got.Subject != "Sign in" || !strings.Contains(got.Body, "confirm?token=abc") {
+ t.Fatalf("sealed payload did not round-trip: %+v", got)
+ }
+ if got.Kind != "app" {
+ t.Fatalf("kind = %q, want app", got.Kind)
+ }
+}
+
+func TestSendAppRevokedGrant(t *testing.T) {
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ km := NewKeymail(fk.srv.URL, "http://127.0.0.1:8090")
+ km.SetSigningKey(testSigningKey(t))
+ verifier, _ := NewVerifier()
+ fk.challenge = ChallengeS256(verifier)
+ fk.expectCode = "code-1"
+ _, token, err := km.Exchange(context.Background(), "code-1", verifier)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fk.send401 = true
+ if _, err := km.SendApp(context.Background(), token, "paul@keymail.dev", "s", "b"); !errors.Is(err, ErrGrantRevoked) {
+ t.Fatalf("err = %v, want ErrGrantRevoked", err)
+ }
+}
+
+func TestSendAppWithoutKey(t *testing.T) {
+ km := NewKeymail("https://keymail.dev", "https://platform.carlosframework.com")
+ if _, err := km.SendApp(context.Background(), "tok", "paul@keymail.dev", "s", "b"); err == nil {
+ t.Fatal("SendApp without a signing key should refuse")
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
new file mode 100644
index 000000000..10f103873
--- /dev/null
+++ b/internal/console/server.go
@@ -0,0 +1,446 @@
+// The console's HTTP server: hand-written HTML from Go templates, no
+// JavaScript, no build step, no external resources. Auth model: OAuth at
+// Keymail is the ONE-TIME sign-up ceremony; returning sign-ins are a
+// single-use confirmation link the console delivers into the user's Keymail
+// Apps inbox with its stored grant.
+package console
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/subtle"
+ "embed"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+//go:embed templates/*.html
+var tmplFS embed.FS
+
+var templates = template.Must(template.New("").Funcs(template.FuncMap{
+ "fmtTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") },
+}).ParseFS(tmplFS, "templates/*.html"))
+
+const (
+ sessionCookie = "carlos_console_session"
+ authCookie = "carlos_console_auth"
+ welcomeCookie = "carlos_console_welcome"
+ authFlowTTL = 10 * time.Minute
+)
+
+// Config is everything the server reads its world through.
+type Config struct {
+ Origin string // the console's public origin — the OAuth client_id
+ Keymail string // keymail base URL
+ Registry string // path to the platform registry DB (read-only here)
+ Releases release.Store // the deployment bucket
+ ReleasePub ed25519.PublicKey // pinned channel-pointer verification key
+}
+
+// Server is the console's http.Handler.
+type Server struct {
+ cfg Config
+ store *Store
+ km *Keymail
+ mux *http.ServeMux
+ secure bool // Secure cookies iff the origin is https
+}
+
+// NewServer wires a server over an opened Store, loading (or minting) the
+// console's Keymail signing key.
+func NewServer(cfg Config, st *Store) (*Server, error) {
+ cfg.Origin = strings.TrimSuffix(cfg.Origin, "/")
+ km := NewKeymail(cfg.Keymail, cfg.Origin)
+ key, err := st.SigningKey()
+ if err != nil {
+ return nil, err
+ }
+ km.SetSigningKey(key)
+ s := &Server{
+ cfg: cfg,
+ store: st,
+ km: km,
+ mux: http.NewServeMux(),
+ secure: strings.HasPrefix(cfg.Origin, "https://"),
+ }
+ s.mux.HandleFunc("GET /{$}", s.handleHome)
+ s.mux.HandleFunc("POST /auth/continue", s.handleContinue)
+ s.mux.HandleFunc("GET /auth/signin", s.handleSignin)
+ s.mux.HandleFunc("GET /auth/callback", s.handleCallback)
+ s.mux.HandleFunc("GET /auth/confirm", s.handleConfirm)
+ s.mux.HandleFunc("POST /auth/signout", s.handleSignout)
+ s.mux.HandleFunc("GET /apps", s.handleApps)
+ s.mux.HandleFunc("GET /apps/{app}", s.handleApp)
+ s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintln(w, "ok")
+ })
+ return s, nil
+}
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
+
+// ---- pages ------------------------------------------------------------------
+
+// page is the one bag every template reads from; unused fields are ignored.
+type page struct {
+ Title string
+ Address string // signed-in address, "" when signed out
+ CSRF string // the session token, double-submitted on the signout POST
+ Error string
+ Welcome bool // one-shot "account created" banner
+
+ SubmittedAddr string // interstitial: the address the user typed
+ KeymailURL string // interstitial: link to that address's keymail host
+
+ Msg string // generic message page body
+
+ Apps []appRow // /apps
+ Detail *AppView // /apps/{app}
+}
+
+// appRow is one line of the "your apps" table.
+type appRow struct {
+ App string
+ Hosts int
+ Instances int
+ Statuses string
+ Stable string
+ Canaries string
+}
+
+func (s *Server) render(w http.ResponseWriter, status int, name string, p page) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := templates.ExecuteTemplate(w, name, p); err != nil {
+ log.Printf("console: render %s: %v", name, err)
+ }
+}
+
+func (s *Server) renderMessage(w http.ResponseWriter, r *http.Request, status int, title, msg string) {
+ p := s.basePage(r)
+ p.Title = title
+ p.Msg = msg
+ s.render(w, status, "message", p)
+}
+
+// basePage fills the fields the layout chrome needs (signed-in address and
+// the signout CSRF token).
+func (s *Server) basePage(r *http.Request) page {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ return page{}
+ }
+ return page{Address: addr, CSRF: raw}
+}
+
+// ---- session plumbing -------------------------------------------------------
+
+func (s *Server) session(r *http.Request) (address, rawToken string, ok bool) {
+ c, err := r.Cookie(sessionCookie)
+ if err != nil || c.Value == "" {
+ return "", "", false
+ }
+ addr, ok := s.store.SessionAddress(c.Value, time.Now())
+ if !ok {
+ return "", "", false
+ }
+ return addr, c.Value, true
+}
+
+func (s *Server) setCookie(w http.ResponseWriter, name, value string, maxAge int) {
+ http.SetCookie(w, &http.Cookie{
+ Name: name, Value: value, Path: "/",
+ MaxAge: maxAge, HttpOnly: true, Secure: s.secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func (s *Server) clearCookie(w http.ResponseWriter, name string) {
+ s.setCookie(w, name, "", -1)
+}
+
+// ---- auth handlers ----------------------------------------------------------
+
+func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
+ if _, _, ok := s.session(r); ok {
+ http.Redirect(w, r, "/apps", http.StatusFound)
+ return
+ }
+ p := page{Title: "Sign in"}
+ s.render(w, http.StatusOK, "home", p)
+}
+
+// handleContinue is the one button: empty address → the OAuth sign-up
+// ceremony; an address → a confirmation link into its Keymail Apps inbox.
+// The interstitial renders identically whether or not the address is known,
+// and whether or not the send succeeded — the console never confirms an
+// account's existence (anti-enumeration).
+func (s *Server) handleContinue(w http.ResponseWriter, r *http.Request) {
+ now := time.Now()
+ s.store.PurgeExpired(now) // housekeeping on the write path
+ addr := strings.ToLower(strings.TrimSpace(r.FormValue("address")))
+ if addr == "" {
+ http.Redirect(w, r, "/auth/signin", http.StatusSeeOther)
+ return
+ }
+ if !validAddress(addr) {
+ p := page{Title: "Sign in", Error: "That doesn't look like a keymail address — expected something like you@keymail.dev."}
+ s.render(w, http.StatusOK, "home", p)
+ return
+ }
+ if tok, _ := s.store.GrantToken(addr); tok != "" {
+ if raw, err := s.store.CreateConfirmation(addr, now); err == nil {
+ link := s.cfg.Origin + "/auth/confirm?token=" + url.QueryEscape(raw)
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ _, err := s.km.SendApp(ctx, tok, addr,
+ "Sign in to the CARLOS platform console",
+ "Someone (hopefully you) asked to sign in to the CARLOS platform console as "+addr+".\n\n"+
+ "This link works once and expires in 15 minutes:\n\n"+link+"\n\n"+
+ "If this wasn't you, ignore this message — nothing happens without the link.")
+ if err != nil {
+ // Best-effort by design: the interstitial shows regardless, so a
+ // dead grant (401) or a keymail hiccup reveals nothing here. The
+ // interstitial's OAuth fallback is the road back.
+ log.Printf("console: confirmation send to %s: %v", addr, err)
+ }
+ }
+ }
+ p := page{
+ Title: "Check your Keymail",
+ SubmittedAddr: addr,
+ KeymailURL: "https://" + addr[strings.LastIndex(addr, "@")+1:] + "/",
+ }
+ s.render(w, http.StatusOK, "check", p)
+}
+
+func validAddress(a string) bool {
+ i := strings.LastIndex(a, "@")
+ return i > 0 && i < len(a)-1 && !strings.ContainsAny(a, " \t\r\n")
+}
+
+// handleSignin starts the one-time OAuth ceremony: state + PKCE verifier in
+// a short-lived cookie, browser off to Keymail's consent page.
+func (s *Server) handleSignin(w http.ResponseWriter, r *http.Request) {
+ state, err := NewVerifier()
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.")
+ return
+ }
+ verifier, err := NewVerifier()
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the sign-in flow. Please try again.")
+ return
+ }
+ s.setCookie(w, authCookie, state+"."+verifier, int(authFlowTTL.Seconds()))
+ http.Redirect(w, r, s.km.AuthorizeURL(state, ChallengeS256(verifier)), http.StatusSeeOther)
+}
+
+// handleCallback finishes the ceremony: verify state, exchange the code
+// (with PKCE verifier and the console's sign_pub), and either sign the user
+// up (new address) or in. The grant token is stored per user — it is the
+// instrument of future confirmation-link sign-ins.
+func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ if e := q.Get("error"); e != "" {
+ p := page{Title: "Sign in", Error: "Sign-in was cancelled at Keymail (" + e + ")."}
+ s.render(w, http.StatusOK, "home", p)
+ return
+ }
+ c, err := r.Cookie(authCookie)
+ if err != nil || c.Value == "" {
+ s.renderMessage(w, r, http.StatusBadRequest, "Sign-in flow expired",
+ "This sign-in attempt is stale or was started elsewhere. Please start again.")
+ return
+ }
+ state, verifier, ok := strings.Cut(c.Value, ".")
+ if !ok || state == "" ||
+ subtle.ConstantTimeCompare([]byte(state), []byte(q.Get("state"))) != 1 {
+ s.renderMessage(w, r, http.StatusBadRequest, "Sign-in flow mismatch",
+ "The sign-in response didn't match this browser's sign-in attempt. Please start again.")
+ return
+ }
+ code := q.Get("code")
+ if code == "" {
+ s.renderMessage(w, r, http.StatusBadRequest, "Sign-in failed", "Keymail sent no authorization code. Please start again.")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ address, token, err := s.km.Exchange(ctx, code, verifier)
+ if err != nil {
+ log.Printf("console: token exchange: %v", err)
+ s.renderMessage(w, r, http.StatusBadGateway, "Sign-in failed",
+ "Keymail did not accept the sign-in. Please start again.")
+ return
+ }
+ now := time.Now()
+ created, err := s.store.EnsureUser(address, now)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your account. Please try again.")
+ return
+ }
+ if err := s.store.SetGrantToken(address, token, now); err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not store your sign-in grant. Please try again.")
+ return
+ }
+ raw, err := s.store.CreateSession(address, token, now)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create your session. Please try again.")
+ return
+ }
+ s.clearCookie(w, authCookie)
+ s.setCookie(w, sessionCookie, raw, int(SessionTTL.Seconds()))
+ if created {
+ s.setCookie(w, welcomeCookie, "1", 300)
+ }
+ http.Redirect(w, r, "/apps", http.StatusSeeOther)
+}
+
+// handleConfirm consumes a returning-sign-in confirmation link. The token is
+// burned before the expiry verdict (store.TakeConfirmation), so a link works
+// exactly once.
+func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.store.TakeConfirmation(r.URL.Query().Get("token"), time.Now())
+ if !ok {
+ s.renderMessage(w, r, http.StatusGone, "Link expired",
+ "That sign-in link has expired or was already used. Ask for a fresh one from the front page.")
+ return
+ }
+ now := time.Now()
+ s.store.EnsureUser(addr, now) // belt and braces; confirmations only exist for known users
+ tok, _ := s.store.GrantToken(addr)
+ raw, err := s.store.CreateSession(addr, tok, now)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create your session. Please try again.")
+ return
+ }
+ s.setCookie(w, sessionCookie, raw, int(SessionTTL.Seconds()))
+ http.Redirect(w, r, "/apps", http.StatusSeeOther)
+}
+
+// handleSignout deletes the console session. The Keymail grant deliberately
+// survives — it is how the next sign-in reaches the user; revoking it lives
+// in Keymail's own Connected-apps settings.
+func (s *Server) handleSignout(w http.ResponseWriter, r *http.Request) {
+ _, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ // CSRF: the session token double-submitted as a hidden form field.
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ s.store.DeleteSession(raw)
+ s.clearCookie(w, sessionCookie)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// ---- app pages --------------------------------------------------------------
+
+func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ p := page{Title: "Your apps", Address: addr, CSRF: raw}
+ if c, err := r.Cookie(welcomeCookie); err == nil && c.Value == "1" {
+ p.Welcome = true
+ s.clearCookie(w, welcomeCookie)
+ }
+ apps, err := s.store.AppsFor(addr)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
+ return
+ }
+ if len(apps) > 0 {
+ views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, apps)
+ if err != nil {
+ log.Printf("console: assemble: %v", err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
+ return
+ }
+ for _, v := range views {
+ p.Apps = append(p.Apps, summarize(v))
+ }
+ }
+ s.render(w, http.StatusOK, "apps", p)
+}
+
+func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ app := r.PathValue("app")
+ apps, err := s.store.AppsFor(addr)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
+ return
+ }
+ owned := false
+ for _, a := range apps {
+ owned = owned || a == app
+ }
+ if !owned {
+ // Not owned and nonexistent look identical on purpose.
+ s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.")
+ return
+ }
+ views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, []string{app})
+ if err != nil || len(views) != 1 {
+ log.Printf("console: assemble %s: %v", app, err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
+ return
+ }
+ p := page{Title: app, Address: addr, CSRF: raw, Detail: &views[0]}
+ s.render(w, http.StatusOK, "app", p)
+}
+
+// summarize folds one AppView into its "your apps" table row.
+func summarize(v AppView) appRow {
+ row := appRow{App: v.App, Hosts: len(v.Routes)}
+ counts := map[string]int{}
+ for _, rt := range v.Routes {
+ if rt.Kind == "instance" {
+ row.Instances++
+ }
+ counts[rt.Status]++
+ }
+ statuses := make([]string, 0, len(counts))
+ for st := range counts {
+ statuses = append(statuses, st)
+ }
+ sort.Strings(statuses)
+ parts := make([]string, 0, len(statuses))
+ for _, st := range statuses {
+ parts = append(parts, fmt.Sprintf("%d %s", counts[st], st))
+ }
+ row.Statuses = strings.Join(parts, ", ")
+ var canaries []string
+ for _, ch := range v.Channels {
+ if ch.Err != "" {
+ continue
+ }
+ if ch.Name == "stable" {
+ row.Stable = ch.Version
+ }
+ if strings.HasPrefix(ch.Name, "canary/") {
+ canaries = append(canaries, ch.Name+" "+ch.Version)
+ }
+ }
+ row.Canaries = strings.Join(canaries, ", ")
+ return row
+}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
new file mode 100644
index 000000000..3c07bd84b
--- /dev/null
+++ b/internal/console/server_test.go
@@ -0,0 +1,332 @@
+package console
+
+import (
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// consoleFixture is a whole console: fixture platform (registry + bucket
+// from view_test.go), console store, fake keymail, and the server under an
+// httptest listener whose URL is its own OAuth origin.
+type consoleFixture struct {
+ ts *httptest.Server
+ st *Store
+ fk *fakeKeymail
+}
+
+func newConsoleFixture(t *testing.T) *consoleFixture {
+ t.Helper()
+ regPath, relStore, pub := buildFixtures(t)
+ st, err := Open(filepath.Join(t.TempDir(), "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ fk := newFakeKeymail(t, "paul@keymail.dev")
+ // The origin must equal the listener's URL, which only exists after the
+ // listener does — hence the indirection.
+ var srv *Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = NewServer(Config{
+ Origin: ts.URL,
+ Keymail: fk.srv.URL,
+ Registry: regPath,
+ Releases: relStore,
+ ReleasePub: pub,
+ }, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.Grant("paul@keymail.dev", "kass"); err != nil {
+ t.Fatal(err)
+ }
+ return &consoleFixture{ts: ts, st: st, fk: fk}
+}
+
+func (fx *consoleFixture) setUser(address string) {
+ fx.fk.mu.Lock()
+ defer fx.fk.mu.Unlock()
+ fx.fk.address = address
+}
+
+// newBrowser is a cookie-jarred client that never follows redirects, so
+// every hop is assertable.
+func newBrowser(t *testing.T) *http.Client {
+ t.Helper()
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &http.Client{
+ Jar: jar,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+}
+
+func get(t *testing.T, c *http.Client, u string) (status int, location, body string) {
+ t.Helper()
+ res, err := c.Get(u)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, res.Header.Get("Location"), string(raw)
+}
+
+func postForm(t *testing.T, c *http.Client, u string, form url.Values) (status int, location, body string) {
+ t.Helper()
+ res, err := c.PostForm(u, form)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, res.Header.Get("Location"), string(raw)
+}
+
+// signUp walks the whole OAuth ceremony: /auth/signin's redirect is parsed
+// for state + PKCE challenge (handed to the fake, which verifies the
+// verifier at exchange), then the callback lands with a code.
+func signUp(t *testing.T, fx *consoleFixture, c *http.Client, code string) {
+ t.Helper()
+ status, loc, _ := get(t, c, fx.ts.URL+"/auth/signin")
+ if status != http.StatusSeeOther {
+ t.Fatalf("signin = %d, want 303", status)
+ }
+ u, err := url.Parse(loc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ km, _ := url.Parse(fx.fk.srv.URL)
+ if u.Host != km.Host || u.Path != "/oauth/authorize" {
+ t.Fatalf("authorize redirect went to %v, want %s/oauth/authorize", u, km.Host)
+ }
+ q := u.Query()
+ if q.Get("client_id") != fx.ts.URL || q.Get("redirect_uri") != fx.ts.URL+"/auth/callback" {
+ t.Fatalf("authorize params = %v", q)
+ }
+ if q.Get("scope") != "identify send" || q.Get("code_challenge_method") != "S256" {
+ t.Fatalf("authorize params = %v", q)
+ }
+ fx.fk.mu.Lock()
+ fx.fk.challenge = q.Get("code_challenge")
+ fx.fk.expectCode = code
+ fx.fk.mu.Unlock()
+ status, loc, _ = get(t, c, fx.ts.URL+"/auth/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(q.Get("state")))
+ if status != http.StatusSeeOther || loc != "/apps" {
+ t.Fatalf("callback = %d -> %q, want 303 -> /apps", status, loc)
+ }
+}
+
+func sessionToken(t *testing.T, fx *consoleFixture, c *http.Client) string {
+ t.Helper()
+ u, _ := url.Parse(fx.ts.URL)
+ for _, ck := range c.Jar.Cookies(u) {
+ if ck.Name == sessionCookie {
+ return ck.Value
+ }
+ }
+ t.Fatal("no session cookie in jar")
+ return ""
+}
+
+func TestConsoleE2E(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+
+ if status, _, body := get(t, c, fx.ts.URL+"/healthz"); status != 200 || !strings.Contains(body, "ok") {
+ t.Fatalf("healthz = %d %q", status, body)
+ }
+ if status, loc, _ := get(t, c, fx.ts.URL+"/apps"); status != http.StatusFound || loc != "/" {
+ t.Fatalf("signed-out /apps = %d -> %q, want 302 -> /", status, loc)
+ }
+ if _, _, body := get(t, c, fx.ts.URL+"/"); !strings.Contains(body, "Continue with Keymail") {
+ t.Fatal("landing page is missing the continue button")
+ }
+
+ signUp(t, fx, c, "code-1")
+
+ // First view: kass listed (and only kass), with the account-created flash.
+ _, _, body := get(t, c, fx.ts.URL+"/apps")
+ for _, want := range []string{"Account created", "paul@keymail.dev", "kass", "v1", "canary/x v2"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/apps missing %q in:\n%s", want, body)
+ }
+ }
+ if strings.Contains(body, "otherapp") {
+ t.Fatal("/apps lists an app the user does not own")
+ }
+ // The flash is one-shot.
+ if _, _, body := get(t, c, fx.ts.URL+"/apps"); strings.Contains(body, "Account created") {
+ t.Fatal("welcome flash survived a second page view")
+ }
+
+ // Detail page: routes and channels, with the bake approximation labeled.
+ status, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ if status != 200 {
+ t.Fatalf("/apps/kass = %d", status)
+ }
+ for _, want := range []string{
+ "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "instance", "static",
+ "ready", "v1", "stable", "canary/x", "v2",
+ "bakes until ~", "<td>live</td>", "approximation",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
+ }
+ }
+
+ // Apps not owned 404 exactly like apps that don't exist.
+ if status, _, _ := get(t, c, fx.ts.URL+"/apps/otherapp"); status != http.StatusNotFound {
+ t.Fatalf("/apps/otherapp = %d, want 404", status)
+ }
+ if status, _, _ := get(t, c, fx.ts.URL+"/apps/nosuchapp"); status != http.StatusNotFound {
+ t.Fatalf("/apps/nosuchapp = %d, want 404", status)
+ }
+
+ // Signout: wrong CSRF refused, right CSRF signs out.
+ raw := sessionToken(t, fx, c)
+ if status, _, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {"wrong"}}); status != http.StatusForbidden {
+ t.Fatalf("signout with bad csrf = %d, want 403", status)
+ }
+ if status, loc, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {raw}}); status != http.StatusSeeOther || loc != "/" {
+ t.Fatalf("signout = %d -> %q, want 303 -> /", status, loc)
+ }
+ if status, _, _ := get(t, c, fx.ts.URL+"/apps"); status != http.StatusFound {
+ t.Fatalf("post-signout /apps = %d, want redirect", status)
+ }
+}
+
+var confirmLinkRe = regexp.MustCompile(`https?://[^\s"]+/auth/confirm\?token=[A-Za-z0-9_%.~-]+`)
+
+func TestReturningSignInViaConfirmationLink(t *testing.T) {
+ fx := newConsoleFixture(t)
+ signUp(t, fx, newBrowser(t), "code-1") // the one-time ceremony stores the grant
+
+ // A fresh browser, no session: the console sends a confirmation link
+ // into the Apps inbox using the stored grant.
+ c := newBrowser(t)
+ status, _, body := postForm(t, c, fx.ts.URL+"/auth/continue", url.Values{"address": {"paul@keymail.dev"}})
+ if status != 200 || !strings.Contains(body, "Check your Keymail") {
+ t.Fatalf("continue = %d %q", status, body)
+ }
+ fx.fk.mu.Lock()
+ if len(fx.fk.sends) != 1 {
+ fx.fk.mu.Unlock()
+ t.Fatalf("keymail got %d sends, want 1", len(fx.fk.sends))
+ }
+ sent := fx.fk.sends[0]
+ fx.fk.mu.Unlock()
+ if sent.To != "paul@keymail.dev" || !strings.Contains(sent.Subject, "Sign in") {
+ t.Fatalf("sent = %+v", sent)
+ }
+ link := confirmLinkRe.FindString(sent.Body)
+ if link == "" {
+ t.Fatalf("no confirmation link in sealed body: %q", sent.Body)
+ }
+
+ // The link mints a session — once.
+ if status, loc, _ := get(t, c, link); status != http.StatusSeeOther || loc != "/apps" {
+ t.Fatalf("confirm = %d -> %q, want 303 -> /apps", status, loc)
+ }
+ _, _, body = get(t, c, fx.ts.URL+"/apps")
+ if !strings.Contains(body, "kass") {
+ t.Fatal("confirmed session doesn't see the user's apps")
+ }
+ if strings.Contains(body, "Account created") {
+ t.Fatal("returning sign-in flashed the account-created banner")
+ }
+ if status, _, body := get(t, newBrowser(t), link); status != http.StatusGone || !strings.Contains(body, "already used") {
+ t.Fatalf("reused link = %d %q, want 410", status, body)
+ }
+}
+
+func TestContinueIndistinguishableAndOAuthFallback(t *testing.T) {
+ fx := newConsoleFixture(t)
+ signUp(t, fx, newBrowser(t), "code-1")
+
+ normalize := func(body, addr string) string { return strings.ReplaceAll(body, addr, "ADDR") }
+
+ sKnown, _, known := postForm(t, newBrowser(t), fx.ts.URL+"/auth/continue", url.Values{"address": {"paul@keymail.dev"}})
+ sUnknown, _, unknown := postForm(t, newBrowser(t), fx.ts.URL+"/auth/continue", url.Values{"address": {"stranger@keymail.dev"}})
+ if sKnown != sUnknown {
+ t.Fatalf("status differs: known %d, unknown %d", sKnown, sUnknown)
+ }
+ if normalize(known, "paul@keymail.dev") != normalize(unknown, "stranger@keymail.dev") {
+ t.Fatal("interstitial reveals whether an address is known")
+ }
+
+ // A revoked grant (send 401) must render the same interstitial — the
+ // OAuth ceremony link on it is the road back, and it must always be there.
+ fx.fk.mu.Lock()
+ fx.fk.send401 = true
+ fx.fk.mu.Unlock()
+ sRevoked, _, revoked := postForm(t, newBrowser(t), fx.ts.URL+"/auth/continue", url.Values{"address": {"paul@keymail.dev"}})
+ if sRevoked != sKnown || normalize(revoked, "paul@keymail.dev") != normalize(unknown, "stranger@keymail.dev") {
+ t.Fatal("revoked-grant interstitial is distinguishable")
+ }
+ for _, body := range []string{known, unknown, revoked} {
+ if !strings.Contains(body, `href="/auth/signin"`) {
+ t.Fatal("interstitial is missing the OAuth fallback link")
+ }
+ }
+}
+
+func TestSecondOAuthIsPlainSignIn(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c1 := newBrowser(t)
+ signUp(t, fx, c1, "code-1")
+ get(t, c1, fx.ts.URL+"/apps") // consume the flash
+
+ c2 := newBrowser(t)
+ signUp(t, fx, c2, "code-2") // same address, fresh ceremony: no re-signup
+ if _, _, body := get(t, c2, fx.ts.URL+"/apps"); strings.Contains(body, "Account created") {
+ t.Fatal("second OAuth for a known address flashed account-created")
+ }
+ fx.fk.mu.Lock()
+ defer fx.fk.mu.Unlock()
+ if fx.fk.exchanges != 2 {
+ t.Fatalf("exchanges = %d, want 2", fx.fk.exchanges)
+ }
+}
+
+func TestEmptyStateWelcomesNewUser(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.setUser("bob@keymail.dev") // no grants for bob
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-b")
+ status, _, body := get(t, c, fx.ts.URL+"/apps")
+ if status != 200 {
+ t.Fatalf("/apps = %d", status)
+ }
+ for _, want := range []string{
+ "Account created", "Welcome to the CARLOS platform",
+ "bob@keymail.dev", "coming soon",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("empty state missing %q in:\n%s", want, body)
+ }
+ }
+ if strings.Contains(body, "kass") || strings.Contains(body, "otherapp") {
+ t.Fatal("empty state leaked someone else's apps")
+ }
+}
diff --git a/internal/console/store.go b/internal/console/store.go
new file mode 100644
index 000000000..cd7211f89
--- /dev/null
+++ b/internal/console/store.go
@@ -0,0 +1,370 @@
+// Package console is the platform console behind
+// https://platform.carlosframework.com: sign up / sign in with Keymail, see
+// your apps and their live platform state. Slice 1 is strictly READ-ONLY
+// against platform state — the console reads the registry and the release
+// bucket, and mutates nothing but its own little SQLite.
+package console
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "database/sql"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+const (
+ // SessionTTL is how long a console session cookie stays valid.
+ SessionTTL = 30 * 24 * time.Hour
+ // ConfirmationTTL bounds a returning-sign-in confirmation link.
+ ConfirmationTTL = 15 * time.Minute
+)
+
+// Store is the console's own SQLite: who exists, who owns what, who is
+// signed in, and the console's Keymail credentials. Platform state (routes,
+// channels) is never in here — that is read live from the registry and the
+// deployment bucket.
+type Store struct {
+ db *sql.DB
+}
+
+// Open opens (creating if needed) the console DB at path.
+func Open(path string) (*Store, error) {
+ if dir := filepath.Dir(path); dir != "." && dir != "" {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, err
+ }
+ }
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ // One writer at a time keeps WAL happy.
+ db.SetMaxOpenConns(1)
+ // busy_timeout FIRST: journal_mode=WAL takes a lock, and with no timeout
+ // set yet a concurrent opener fails immediately with SQLITE_BUSY.
+ for _, p := range []string{
+ "PRAGMA busy_timeout=5000",
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA synchronous=NORMAL",
+ } {
+ if _, err := db.Exec(p); err != nil {
+ db.Close()
+ return nil, err
+ }
+ }
+ s := &Store{db: db}
+ if err := s.migrate(); err != nil {
+ db.Close()
+ return nil, err
+ }
+ return s, nil
+}
+
+// Close releases the underlying database handle.
+func (s *Store) Close() error { return s.db.Close() }
+
+// migrate is additive-only (CREATE TABLE IF NOT EXISTS); future columns go
+// in the ALTER list, whose "duplicate column" error is swallowed so the list
+// stays idempotent.
+func (s *Store) migrate() error {
+ for _, stmt := range []string{
+ `CREATE TABLE IF NOT EXISTS owners (
+ address TEXT NOT NULL,
+ app TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ UNIQUE(address, app)
+);`,
+ `CREATE TABLE IF NOT EXISTS sessions (
+ token_hash TEXT PRIMARY KEY,
+ address TEXT NOT NULL,
+ keymail_token TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);`,
+ `CREATE TABLE IF NOT EXISTS users (
+ address TEXT PRIMARY KEY,
+ created_at INTEGER NOT NULL
+);`,
+ `CREATE TABLE IF NOT EXISTS grants (
+ address TEXT PRIMARY KEY,
+ token TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);`,
+ `CREATE TABLE IF NOT EXISTS confirmations (
+ token_hash TEXT PRIMARY KEY,
+ address TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);`,
+ `CREATE TABLE IF NOT EXISTS keys (
+ name TEXT PRIMARY KEY,
+ pkcs8 TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);`,
+ } {
+ if _, err := s.db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ // Additive column changes land here, forgiving "duplicate column" so
+ // re-running is always safe. (None yet in slice 1.)
+ for _, stmt := range []string{} {
+ if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
+ return err
+ }
+ }
+ return nil
+}
+
+// ---- owners -----------------------------------------------------------------
+
+// Grant makes address an owner of app (idempotent).
+func (s *Store) Grant(address, app string) error {
+ _, err := s.db.Exec(`INSERT OR IGNORE INTO owners (address, app, created_at) VALUES (?, ?, ?)`,
+ address, app, time.Now().Unix())
+ return err
+}
+
+// Revoke removes address's ownership of app.
+func (s *Store) Revoke(address, app string) error {
+ _, err := s.db.Exec(`DELETE FROM owners WHERE address = ? AND app = ?`, address, app)
+ return err
+}
+
+// AppsFor returns the apps address owns, sorted.
+func (s *Store) AppsFor(address string) ([]string, error) {
+ rows, err := s.db.Query(`SELECT app FROM owners WHERE address = ? ORDER BY app`, address)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var a string
+ if err := rows.Scan(&a); err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+// Owners returns every grant, keyed by owner address, apps sorted.
+func (s *Store) Owners() (map[string][]string, error) {
+ rows, err := s.db.Query(`SELECT address, app FROM owners ORDER BY address, app`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := map[string][]string{}
+ for rows.Next() {
+ var addr, app string
+ if err := rows.Scan(&addr, &app); err != nil {
+ return nil, err
+ }
+ out[addr] = append(out[addr], app)
+ }
+ return out, rows.Err()
+}
+
+// ---- users ------------------------------------------------------------------
+
+// EnsureUser records address as a console user if it isn't one yet. created
+// reports whether this call was the sign-up.
+func (s *Store) EnsureUser(address string, now time.Time) (created bool, err error) {
+ res, err := s.db.Exec(`INSERT INTO users (address, created_at) VALUES (?, ?)
+ON CONFLICT(address) DO NOTHING`, address, now.Unix())
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n > 0, err
+}
+
+// ---- grants (the console's Keymail access tokens) ---------------------------
+
+// SetGrantToken stores the Keymail access token the user's OAuth approval
+// minted for the console — the instrument of future confirmation-link
+// sign-ins. Server-side only; it never reaches a browser.
+func (s *Store) SetGrantToken(address, token string, now time.Time) error {
+ _, err := s.db.Exec(`INSERT INTO grants (address, token, created_at, updated_at)
+VALUES (?, ?, ?, ?)
+ON CONFLICT(address) DO UPDATE SET token = excluded.token, updated_at = excluded.updated_at`,
+ address, token, now.Unix(), now.Unix())
+ return err
+}
+
+// GrantToken returns the stored Keymail token for address, "" when none.
+func (s *Store) GrantToken(address string) (string, error) {
+ var tok string
+ err := s.db.QueryRow(`SELECT token FROM grants WHERE address = ?`, address).Scan(&tok)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return tok, err
+}
+
+// ---- sessions ---------------------------------------------------------------
+
+func hashToken(t string) string {
+ h := sha256.Sum256([]byte(t))
+ return hex.EncodeToString(h[:])
+}
+
+func randomToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// CreateSession mints a console session for address, returning the raw
+// token the cookie carries. Only the sha256 of it is stored.
+func (s *Store) CreateSession(address, keymailToken string, now time.Time) (string, error) {
+ raw, err := randomToken()
+ if err != nil {
+ return "", err
+ }
+ _, err = s.db.Exec(`INSERT INTO sessions (token_hash, address, keymail_token, created_at, expires_at)
+VALUES (?, ?, ?, ?, ?)`,
+ hashToken(raw), address, keymailToken, now.Unix(), now.Add(SessionTTL).Unix())
+ if err != nil {
+ return "", err
+ }
+ return raw, nil
+}
+
+// SessionAddress resolves a raw session token to its address; ok is false
+// for unknown or expired tokens.
+func (s *Store) SessionAddress(rawToken string, now time.Time) (string, bool) {
+ var addr string
+ var exp int64
+ err := s.db.QueryRow(`SELECT address, expires_at FROM sessions WHERE token_hash = ?`,
+ hashToken(rawToken)).Scan(&addr, &exp)
+ if err != nil || now.Unix() > exp {
+ return "", false
+ }
+ return addr, true
+}
+
+// DeleteSession removes a session by its raw token.
+func (s *Store) DeleteSession(rawToken string) error {
+ _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, hashToken(rawToken))
+ return err
+}
+
+// PurgeExpired drops expired sessions and confirmation tokens.
+func (s *Store) PurgeExpired(now time.Time) error {
+ if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.Unix()); err != nil {
+ return err
+ }
+ _, err := s.db.Exec(`DELETE FROM confirmations WHERE expires_at < ?`, now.Unix())
+ return err
+}
+
+// ---- confirmations (returning sign-in links) --------------------------------
+
+// CreateConfirmation mints a single-use sign-in confirmation token for
+// address (15 min TTL), returning the raw token for the emailed link. Only
+// its hash is stored.
+func (s *Store) CreateConfirmation(address string, now time.Time) (string, error) {
+ raw, err := randomToken()
+ if err != nil {
+ return "", err
+ }
+ _, err = s.db.Exec(`INSERT INTO confirmations (token_hash, address, created_at, expires_at)
+VALUES (?, ?, ?, ?)`,
+ hashToken(raw), address, now.Unix(), now.Add(ConfirmationTTL).Unix())
+ if err != nil {
+ return "", err
+ }
+ return raw, nil
+}
+
+// TakeConfirmation consumes a confirmation token: the row is deleted before
+// the expiry verdict (burn-then-check, keymail takeOAuthCode manners), so a
+// token can never be retried into validity.
+func (s *Store) TakeConfirmation(rawToken string, now time.Time) (string, bool) {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return "", false
+ }
+ defer tx.Rollback()
+ var addr string
+ var exp int64
+ err = tx.QueryRow(`SELECT address, expires_at FROM confirmations WHERE token_hash = ?`,
+ hashToken(rawToken)).Scan(&addr, &exp)
+ if err != nil {
+ return "", false
+ }
+ if _, err := tx.Exec(`DELETE FROM confirmations WHERE token_hash = ?`, hashToken(rawToken)); err != nil {
+ return "", false
+ }
+ if err := tx.Commit(); err != nil {
+ return "", false
+ }
+ if now.Unix() > exp {
+ return "", false
+ }
+ return addr, true
+}
+
+// ---- keys -------------------------------------------------------------------
+
+// SigningKey returns the console's long-lived envelope-signing key (ECDSA
+// P-256 — the curve keymail's app API pins in a grant), generating and
+// persisting it on first use.
+func (s *Store) SigningKey() (*ecdsa.PrivateKey, error) {
+ load := func() (*ecdsa.PrivateKey, error) {
+ var b64 string
+ if err := s.db.QueryRow(`SELECT pkcs8 FROM keys WHERE name = 'keymail-sign'`).Scan(&b64); err != nil {
+ return nil, err
+ }
+ der, err := base64.StdEncoding.DecodeString(b64)
+ if err != nil {
+ return nil, fmt.Errorf("console: stored signing key: %v", err)
+ }
+ key, err := x509.ParsePKCS8PrivateKey(der)
+ if err != nil {
+ return nil, fmt.Errorf("console: stored signing key: %v", err)
+ }
+ ec, ok := key.(*ecdsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("console: stored signing key is not ECDSA")
+ }
+ return ec, nil
+ }
+ if k, err := load(); err == nil {
+ return k, nil
+ } else if err != sql.ErrNoRows {
+ return nil, err
+ }
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+ der, err := x509.MarshalPKCS8PrivateKey(priv)
+ if err != nil {
+ return nil, err
+ }
+ // INSERT OR IGNORE + re-load: if a racing opener won, everyone converges
+ // on the row that stuck.
+ if _, err := s.db.Exec(`INSERT OR IGNORE INTO keys (name, pkcs8, created_at) VALUES ('keymail-sign', ?, ?)`,
+ base64.StdEncoding.EncodeToString(der), time.Now().Unix()); err != nil {
+ return nil, err
+ }
+ return load()
+}
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
new file mode 100644
index 000000000..f91d4726e
--- /dev/null
+++ b/internal/console/store_test.go
@@ -0,0 +1,228 @@
+package console
+
+import (
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func openTestStore(t *testing.T) *Store {
+ t.Helper()
+ s, err := Open(filepath.Join(t.TempDir(), "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func TestOwnersRoundTrip(t *testing.T) {
+ s := openTestStore(t)
+ for _, g := range [][2]string{
+ {"paul@keymail.dev", "kass"},
+ {"paul@keymail.dev", "kass"}, // idempotent
+ {"paul@keymail.dev", "aria"},
+ {"bob@keymail.dev", "kass"},
+ } {
+ if err := s.Grant(g[0], g[1]); err != nil {
+ t.Fatal(err)
+ }
+ }
+ apps, err := s.AppsFor("paul@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(apps, []string{"aria", "kass"}) {
+ t.Fatalf("AppsFor = %v, want [aria kass]", apps)
+ }
+ owners, err := s.Owners()
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string][]string{
+ "paul@keymail.dev": {"aria", "kass"},
+ "bob@keymail.dev": {"kass"},
+ }
+ if !reflect.DeepEqual(owners, want) {
+ t.Fatalf("Owners = %v, want %v", owners, want)
+ }
+ if err := s.Revoke("paul@keymail.dev", "kass"); err != nil {
+ t.Fatal(err)
+ }
+ apps, _ = s.AppsFor("paul@keymail.dev")
+ if !reflect.DeepEqual(apps, []string{"aria"}) {
+ t.Fatalf("after revoke AppsFor = %v, want [aria]", apps)
+ }
+ if apps, _ := s.AppsFor("nobody@keymail.dev"); len(apps) != 0 {
+ t.Fatalf("AppsFor unknown = %v, want empty", apps)
+ }
+}
+
+func TestSessions(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ raw, err := s.CreateSession("paul@keymail.dev", "paul.km-token", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if raw == "" {
+ t.Fatal("empty raw session token")
+ }
+ if addr, ok := s.SessionAddress(raw, now.Add(time.Hour)); !ok || addr != "paul@keymail.dev" {
+ t.Fatalf("SessionAddress = %q %v, want paul@keymail.dev true", addr, ok)
+ }
+ if _, ok := s.SessionAddress("not-a-token", now); ok {
+ t.Fatal("bogus token resolved")
+ }
+ if _, ok := s.SessionAddress(raw, now.Add(SessionTTL+time.Minute)); ok {
+ t.Fatal("expired session resolved")
+ }
+ if err := s.DeleteSession(raw); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := s.SessionAddress(raw, now); ok {
+ t.Fatal("deleted session resolved")
+ }
+}
+
+func TestPurgeExpired(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ raw, err := s.CreateSession("paul@keymail.dev", "", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ conf, err := s.CreateConfirmation("paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.PurgeExpired(now.Add(SessionTTL + time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := s.SessionAddress(raw, now); ok {
+ t.Fatal("purged session still resolves at an in-window time")
+ }
+ if _, ok := s.TakeConfirmation(conf, now); ok {
+ t.Fatal("purged confirmation still takeable")
+ }
+}
+
+func TestEnsureUser(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ created, err := s.EnsureUser("paul@keymail.dev", now)
+ if err != nil || !created {
+ t.Fatalf("first EnsureUser = %v %v, want true nil", created, err)
+ }
+ created, err = s.EnsureUser("paul@keymail.dev", now.Add(time.Hour))
+ if err != nil || created {
+ t.Fatalf("second EnsureUser = %v %v, want false nil", created, err)
+ }
+}
+
+func TestGrantToken(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ if tok, err := s.GrantToken("paul@keymail.dev"); err != nil || tok != "" {
+ t.Fatalf("GrantToken unknown = %q %v, want empty nil", tok, err)
+ }
+ if err := s.SetGrantToken("paul@keymail.dev", "paul.tok1", now); err != nil {
+ t.Fatal(err)
+ }
+ if tok, _ := s.GrantToken("paul@keymail.dev"); tok != "paul.tok1" {
+ t.Fatalf("GrantToken = %q, want paul.tok1", tok)
+ }
+ // Re-authorizing rotates the token.
+ if err := s.SetGrantToken("paul@keymail.dev", "paul.tok2", now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if tok, _ := s.GrantToken("paul@keymail.dev"); tok != "paul.tok2" {
+ t.Fatalf("rotated GrantToken = %q, want paul.tok2", tok)
+ }
+}
+
+func TestConfirmationSingleUse(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ raw, err := s.CreateConfirmation("paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ addr, ok := s.TakeConfirmation(raw, now.Add(time.Minute))
+ if !ok || addr != "paul@keymail.dev" {
+ t.Fatalf("TakeConfirmation = %q %v, want paul@keymail.dev true", addr, ok)
+ }
+ if _, ok := s.TakeConfirmation(raw, now.Add(time.Minute)); ok {
+ t.Fatal("confirmation token worked twice")
+ }
+}
+
+func TestConfirmationExpiresAndBurns(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ raw, err := s.CreateConfirmation("paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := s.TakeConfirmation(raw, now.Add(ConfirmationTTL+time.Minute)); ok {
+ t.Fatal("expired confirmation accepted")
+ }
+ // Burn-then-check: even at a valid time it is now gone.
+ if _, ok := s.TakeConfirmation(raw, now); ok {
+ t.Fatal("expired take did not burn the token")
+ }
+}
+
+func TestSigningKeyStableAcrossReopen(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ s, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ k1, err := s.SigningKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ k2, err := s.SigningKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !k1.Equal(k2) {
+ t.Fatal("two SigningKey calls returned different keys")
+ }
+ s.Close()
+ s2, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s2.Close()
+ k3, err := s2.SigningKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !k1.Equal(k3) {
+ t.Fatal("signing key changed across reopen")
+ }
+}
+
+func TestReopenMigrationIdempotent(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ s, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Grant("paul@keymail.dev", "kass"); err != nil {
+ t.Fatal(err)
+ }
+ s.Close()
+ s2, err := Open(path)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer s2.Close()
+ apps, err := s2.AppsFor("paul@keymail.dev")
+ if err != nil || len(apps) != 1 {
+ t.Fatalf("after reopen AppsFor = %v %v", apps, err)
+ }
+}
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html
new file mode 100644
index 000000000..4c848dae3
--- /dev/null
+++ b/internal/console/templates/app.html
@@ -0,0 +1,44 @@
+{{define "app"}}{{template "top" .}}
+<h1>{{.Detail.App}}</h1>
+
+<h2>Routes</h2>
+{{if .Detail.Routes}}
+<div class="tablewrap">
+<table>
+ <tr><th>Host</th><th>Kind</th><th>Status</th><th>Version</th><th>Channel</th><th>Hibernate</th></tr>
+ {{range .Detail.Routes}}
+ <tr>
+ <td><code>{{.Host}}</code></td>
+ <td>{{.Kind}}</td>
+ <td>{{.Status}}</td>
+ <td>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</td>
+ <td>{{.Channel}}</td>
+ <td>{{if .Hibernate}}yes{{else}}—{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">No routes yet.</p>{{end}}
+
+<h2>Channels</h2>
+{{if .Detail.Channels}}
+<div class="tablewrap">
+<table>
+ <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th></tr>
+ {{range .Detail.Channels}}
+ <tr>
+ <td>{{.Name}}</td>
+ {{if .Err}}
+ <td colspan="3" class="error">{{.Err}}</td>
+ {{else}}
+ <td><code>{{.Version}}</code></td>
+ <td>{{fmtTime .PromotedAt}}</td>
+ <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}</td>
+ {{end}}
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record.</p>
+{{else}}<p class="small">No channels yet.</p>{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
new file mode 100644
index 000000000..a8cb6ad39
--- /dev/null
+++ b/internal/console/templates/apps.html
@@ -0,0 +1,25 @@
+{{define "apps"}}{{template "top" .}}
+{{if .Welcome}}<p class="banner">Account created — welcome! You signed up as <strong>{{.Address}}</strong>.</p>{{end}}
+{{if .Apps}}
+<h1>Your apps</h1>
+<div class="tablewrap">
+<table>
+ <tr><th>App</th><th>Hosts</th><th>Instances</th><th>Status</th><th>Stable</th><th>Canary</th></tr>
+ {{range .Apps}}
+ <tr>
+ <td><a href="/apps/{{.App}}">{{.App}}</a></td>
+ <td>{{.Hosts}}</td>
+ <td>{{.Instances}}</td>
+ <td>{{if .Statuses}}{{.Statuses}}{{else}}—{{end}}</td>
+ <td>{{if .Stable}}<code>{{.Stable}}</code>{{else}}—{{end}}</td>
+ <td>{{if .Canaries}}{{.Canaries}}{{else}}—{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}
+<h1>Welcome to the CARLOS platform</h1>
+<p>Your account <strong>{{.Address}}</strong> is ready. No apps live here yet — creating apps from the console is coming soon.</p>
+<p class="small">When an app is granted to this address, it appears right here with its live routes and release channels.</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/check.html b/internal/console/templates/check.html
new file mode 100644
index 000000000..be30d57fa
--- /dev/null
+++ b/internal/console/templates/check.html
@@ -0,0 +1,6 @@
+{{define "check"}}{{template "top" .}}
+<h1>Check your Keymail</h1>
+<p>If <strong>{{.SubmittedAddr}}</strong> has signed up here before, a single-use sign-in link is on its way to its <strong>Apps</strong> inbox. The link works once and expires in 15 minutes.</p>
+<p><a class="btn" href="{{.KeymailURL}}">Open Keymail</a></p>
+<p class="small">Nothing arriving, or first time here? <a href="/auth/signin">Approve the console in Keymail instead</a> — that works whether or not this address has been here before, and refreshes the console's permission if you had revoked it.</p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/home.html b/internal/console/templates/home.html
new file mode 100644
index 000000000..6d3c1e7ea
--- /dev/null
+++ b/internal/console/templates/home.html
@@ -0,0 +1,10 @@
+{{define "home"}}{{template "top" .}}
+<h1>The CARLOS platform console</h1>
+<p class="lede">Your apps, their live routes, and their release channels — signed in with nothing but your keymail address.</p>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form class="signin" method="post" action="/auth/continue">
+ <input type="text" name="address" placeholder="you@keymail.dev" autocomplete="email">
+ <button type="submit">Continue with Keymail</button>
+</form>
+<p class="small">First time here? Leave the address empty (or <a href="/auth/signin">go straight to Keymail</a>) — you approve the console once in your Keymail, and that single approval is the whole sign-up. Returning with your address? We deliver a single-use sign-in link to your Keymail <strong>Apps</strong> inbox. Either way: no password, and you can revoke the console from Keymail at any time.</p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
new file mode 100644
index 000000000..b6968d4b3
--- /dev/null
+++ b/internal/console/templates/layout.html
@@ -0,0 +1,107 @@
+{{define "top"}}<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}} · CARLOS console</title>
+<style>
+ :root {
+ --bg: #ffffff; --ink: #1d2029; --muted: #64707f;
+ --accent: #0f5e85; --accent-soft: rgba(15, 94, 133, 0.09);
+ --rule: #e7e9f0; --bad: #b3403a;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #14151c; --ink: #eef0f6; --muted: #93a4ba;
+ --accent: #5db8dd; --accent-soft: rgba(93, 184, 221, 0.12);
+ --rule: #2a2e38; --bad: #e07b76;
+ }
+ }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ background: var(--bg); color: var(--ink);
+ font: 400 1.0625rem/1.65 Charter, Georgia, "Times New Roman", serif;
+ -webkit-font-smoothing: antialiased; padding: 0 1.25rem;
+ }
+ a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
+ code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.88em; background: var(--accent-soft);
+ padding: 0.1em 0.35em; border-radius: 0.25rem;
+ }
+ header {
+ max-width: 56rem; margin: 0 auto; padding: 1.4rem 0;
+ display: flex; align-items: baseline; gap: 1rem;
+ border-bottom: 1px solid var(--rule);
+ }
+ header .brand a { font-weight: 700; color: var(--ink); text-decoration: none; }
+ header nav {
+ margin-left: auto; display: flex; align-items: baseline; gap: 1rem;
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 0.9rem;
+ }
+ header .addr { color: var(--muted); }
+ main { max-width: 56rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; }
+ h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; }
+ h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; }
+ p { max-width: 40rem; margin: 0 0 1rem; }
+ .lede { font-size: 1.15rem; color: var(--muted); }
+ .small { font-size: 0.9rem; color: var(--muted); }
+ .error { color: var(--bad); }
+ .banner {
+ background: var(--accent-soft); border-radius: 0.5rem;
+ padding: 0.6rem 0.9rem; margin: 0 0 1.5rem; max-width: 40rem;
+ }
+ .tablewrap { overflow-x: auto; }
+ table { border-collapse: collapse; width: 100%; margin: 0 0 1rem; font-size: 0.95rem; }
+ th {
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
+ color: var(--muted); text-align: left; font-weight: 600;
+ }
+ th, td { padding: 0.5rem 1rem 0.5rem 0; border-bottom: 1px solid var(--rule); }
+ form.signin { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 1rem; }
+ input[type=text] {
+ font: inherit; color: var(--ink); background: var(--bg);
+ border: 1px solid var(--rule); border-radius: 0.5rem;
+ padding: 0.55rem 0.8rem; min-width: 16rem;
+ }
+ button, .btn {
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-size: 0.95rem; font-weight: 600; background: var(--accent);
+ color: var(--bg); border: 0; border-radius: 0.5rem;
+ padding: 0.6rem 1.2rem; cursor: pointer;
+ text-decoration: none; display: inline-block;
+ }
+ button.linkish {
+ background: none; color: var(--accent); font-weight: 400; padding: 0;
+ border-radius: 0; text-decoration: underline; font-size: 0.9rem; cursor: pointer;
+ }
+ footer {
+ max-width: 56rem; margin: 0 auto; padding: 1.25rem 0 2rem;
+ border-top: 1px solid var(--rule);
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-size: 0.85rem; color: var(--muted);
+ }
+</style>
+</head>
+<body>
+<header>
+ <div class="brand"><a href="/">CARLOS console</a></div>
+ {{if .Address}}<nav>
+ <a href="/apps">Apps</a>
+ <span class="addr">{{.Address}}</span>
+ <form method="post" action="/auth/signout">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button class="linkish" type="submit">Sign out</button>
+ </form>
+ </nav>{{end}}
+</header>
+<main>
+{{end}}
+
+{{define "bottom"}}
+</main>
+<footer>🤖 Console text written by an LLM, per <a href="https://11factor.org/#x">factor X</a>.</footer>
+</body>
+</html>
+{{end}}
diff --git a/internal/console/templates/message.html b/internal/console/templates/message.html
new file mode 100644
index 000000000..69ec75d07
--- /dev/null
+++ b/internal/console/templates/message.html
@@ -0,0 +1,5 @@
+{{define "message"}}{{template "top" .}}
+<h1>{{.Title}}</h1>
+<p>{{.Msg}}</p>
+<p class="small"><a href="/">Back to the console</a></p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/view.go b/internal/console/view.go
new file mode 100644
index 000000000..21a9eb318
--- /dev/null
+++ b/internal/console/view.go
@@ -0,0 +1,145 @@
+// The derived read-model: what a signed-in owner sees. Assembled fresh per
+// page from the platform's two sources of truth — the host registry (routes)
+// and the deployment bucket (signed channel pointers) — and never written
+// back: slice 1 of the console is read-only against platform state.
+package console
+
+import (
+ "context"
+ "crypto/ed25519"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// AppView is everything the console shows about one app.
+type AppView struct {
+ App string
+ Routes []RouteView
+ Channels []ChannelView
+}
+
+// RouteView is one row of the routes table.
+type RouteView struct {
+ Host, Kind, Status, Version, Channel string
+ Hibernate bool
+}
+
+// ChannelView is one row of the channels table.
+type ChannelView struct {
+ Name, Version string
+ PromotedAt time.Time
+ // BakedUntil is zero when the channel is live. Otherwise it is
+ // PromotedAt + the channel's DEFAULT hold (release.DefaultHolds) — an
+ // APPROXIMATION the UI labels "bakes until ~": the authoritative bake
+ // clock is each box's own first-seen record (release/bake.go), which the
+ // console cannot see, and per-deployment hold overrides are not read here.
+ BakedUntil time.Time
+ // Err degrades a missing or invalid channel pointer to one row's error
+ // text instead of a failed page.
+ Err string
+}
+
+// registry.Registry has no Close, so opening per page would leak a handle
+// each request; one cached handle per path instead. Freshness is not
+// sacrificed: Registry.List reads over a fresh connection every call.
+var (
+ regMu sync.Mutex
+ regCache = map[string]*registry.Registry{}
+)
+
+func openRegistry(path string) (*registry.Registry, error) {
+ regMu.Lock()
+ defer regMu.Unlock()
+ if r, ok := regCache[path]; ok {
+ return r, nil
+ }
+ r, err := registry.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ regCache[path] = r
+ return r, nil
+}
+
+// Assemble builds the view for the given (owned) apps: routes from the
+// registry at regPath, channels from the release store s verified against
+// pub. Every app in apps gets an AppView, even an empty one; per-channel
+// failures degrade to ChannelView.Err.
+func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []string) ([]AppView, error) {
+ ctx := context.Background()
+ reg, err := openRegistry(regPath)
+ if err != nil {
+ return nil, err
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return nil, err
+ }
+ byApp := map[string][]RouteView{}
+ for _, rt := range routes {
+ ch := rt.Channel
+ if ch == "" {
+ ch = "stable" // registry contract: empty channel means stable
+ }
+ byApp[rt.App] = append(byApp[rt.App], RouteView{
+ Host: rt.Host, Kind: rt.Kind, Status: rt.Status,
+ Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate,
+ })
+ }
+ now := time.Now()
+ sorted := append([]string(nil), apps...)
+ sort.Strings(sorted)
+ var out []AppView
+ seen := map[string]bool{}
+ for _, app := range sorted {
+ if app == "" || seen[app] {
+ continue
+ }
+ seen[app] = true
+ out = append(out, AppView{
+ App: app,
+ Routes: byApp[app], // List orders by host already
+ Channels: assembleChannels(ctx, s, pub, app, now),
+ })
+ }
+ return out, nil
+}
+
+// assembleChannels lists apps/<app>/channels/ and resolves each pointer.
+func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKey, app string, now time.Time) []ChannelView {
+ prefix := "apps/" + app + "/channels/"
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return []ChannelView{{Name: "(all)", Err: "listing channels: " + err.Error()}}
+ }
+ var names []string
+ for _, k := range keys {
+ name := strings.TrimPrefix(k, prefix)
+ if !strings.HasSuffix(name, ".json") {
+ continue
+ }
+ names = append(names, strings.TrimSuffix(name, ".json"))
+ }
+ sort.Strings(names)
+ var out []ChannelView
+ for _, name := range names {
+ p, err := release.Channel(ctx, s, pub, app, name)
+ if err != nil {
+ out = append(out, ChannelView{Name: name, Err: err.Error()})
+ continue
+ }
+ cv := ChannelView{Name: name, Version: p.Version, PromotedAt: p.PromotedAt}
+ if hold := release.HoldFor(release.DefaultHolds, name); hold > 0 {
+ if until := p.PromotedAt.Add(hold); now.Before(until) {
+ cv.BakedUntil = until
+ }
+ }
+ out = append(out, cv)
+ }
+ return out
+}
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
new file mode 100644
index 000000000..4b1e3d451
--- /dev/null
+++ b/internal/console/view_test.go
@@ -0,0 +1,177 @@
+package console
+
+import (
+ "context"
+ "crypto/ed25519"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// buildFixtures makes a little platform: a registry with routes for apps
+// kass (ready instance on stable) and otherapp, and a deployment bucket with
+// kass v1 promoted to stable and v2 to canary/x. Shared with server_test.go.
+func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed25519.PublicKey) {
+ t.Helper()
+ dir := t.TempDir()
+ regPath = filepath.Join(dir, "registry.db")
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v1", App: "kass", Channel: "stable", Addr: "127.0.0.1:9001"},
+ {Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready",
+ Version: "v1", App: "kass", Hibernate: true},
+ {Host: "otherapp.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "z9", App: "otherapp", Channel: "stable"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ store = release.NewDirStore(filepath.Join(dir, "bucket"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err = release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ for _, v := range []string{"v1", "v2"} {
+ m := release.Manifest{
+ Schema: 1, App: "kass", Version: v, Kind: "binary",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
+ }
+ if err := release.Ship(ctx, store, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := release.Promote(ctx, store, priv, "kass", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.Promote(ctx, store, priv, "kass", "canary/x", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ return regPath, store, pub
+}
+
+func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView {
+ t.Helper()
+ for _, c := range chans {
+ if c.Name == name {
+ return c
+ }
+ }
+ t.Fatalf("channel %s not in %+v", name, chans)
+ return ChannelView{}
+}
+
+func TestAssemble(t *testing.T) {
+ regPath, store, pub := buildFixtures(t)
+ views, err := Assemble(regPath, store, pub, []string{"kass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(views) != 1 || views[0].App != "kass" {
+ t.Fatalf("views = %+v, want exactly kass", views)
+ }
+ v := views[0]
+ if len(v.Routes) != 2 {
+ t.Fatalf("kass routes = %+v, want 2", v.Routes)
+ }
+ // List orders by host: docs.kass... before kass...
+ inst := v.Routes[1]
+ if inst.Host != "kass.carlosapp.dev" || inst.Kind != "instance" || inst.Status != "ready" ||
+ inst.Version != "v1" || inst.Channel != "stable" || inst.Hibernate {
+ t.Fatalf("instance route = %+v", inst)
+ }
+ if st := v.Routes[0]; !st.Hibernate || st.Channel != "stable" {
+ t.Fatalf("static route = %+v (empty registry channel must display as stable)", st)
+ }
+
+ stable := findChannel(t, v.Channels, "stable")
+ if stable.Version != "v1" || stable.Err != "" {
+ t.Fatalf("stable = %+v", stable)
+ }
+ // Promoted moments ago with a 72h default hold: still baking, and the
+ // approximation is PromotedAt + hold.
+ if stable.BakedUntil.IsZero() {
+ t.Fatal("freshly promoted stable should be baking")
+ }
+ if want := stable.PromotedAt.Add(release.DefaultHolds["stable"]); !stable.BakedUntil.Equal(want) {
+ t.Fatalf("BakedUntil = %v, want %v", stable.BakedUntil, want)
+ }
+ canary := findChannel(t, v.Channels, "canary/x")
+ if canary.Version != "v2" || canary.Err != "" {
+ t.Fatalf("canary/x = %+v", canary)
+ }
+ if !canary.BakedUntil.IsZero() {
+ t.Fatalf("canary hold is 0 — BakedUntil should be zero (live), got %v", canary.BakedUntil)
+ }
+ if canary.PromotedAt.IsZero() || time.Since(canary.PromotedAt) > time.Hour {
+ t.Fatalf("PromotedAt = %v", canary.PromotedAt)
+ }
+}
+
+func TestAssembleFiltersToGivenApps(t *testing.T) {
+ regPath, store, pub := buildFixtures(t)
+ views, err := Assemble(regPath, store, pub, []string{"kass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, v := range views {
+ if v.App == "otherapp" {
+ t.Fatal("otherapp leaked into a kass-only view")
+ }
+ for _, rt := range v.Routes {
+ if rt.Host == "otherapp.carlosapp.dev" {
+ t.Fatal("otherapp route leaked")
+ }
+ }
+ }
+}
+
+func TestAssembleEmptyAppStillListed(t *testing.T) {
+ regPath, store, pub := buildFixtures(t)
+ views, err := Assemble(regPath, store, pub, []string{"ghost", "kass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(views) != 2 || views[0].App != "ghost" {
+ t.Fatalf("views = %+v, want ghost then kass", views)
+ }
+ if len(views[0].Routes) != 0 || len(views[0].Channels) != 0 {
+ t.Fatalf("ghost should be empty: %+v", views[0])
+ }
+}
+
+func TestAssembleDegradesBadChannel(t *testing.T) {
+ regPath, store, pub := buildFixtures(t)
+ // A garbage pointer must become one row's error, never a failed page.
+ if _, err := store.Put(context.Background(), "apps/kass/channels/beta.json", []byte("not a signed pointer"), ""); err != nil {
+ t.Fatal(err)
+ }
+ views, err := Assemble(regPath, store, pub, []string{"kass"})
+ if err != nil {
+ t.Fatalf("Assemble must not fail on a bad channel: %v", err)
+ }
+ beta := findChannel(t, views[0].Channels, "beta")
+ if beta.Err == "" {
+ t.Fatalf("beta = %+v, want an Err", beta)
+ }
+ if stable := findChannel(t, views[0].Channels, "stable"); stable.Err != "" {
+ t.Fatalf("healthy channel degraded too: %+v", stable)
+ }
+}