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>
15 files changed,
+2785
−0
cmd/console/main.go+134 −0internal/console/keymail.go+349 −0internal/console/keymail_test.go+407 −0internal/console/server.go+446 −0internal/console/server_test.go+332 −0internal/console/store.go+370 −0internal/console/store_test.go+228 −0internal/console/templates/app.html+44 −0internal/console/templates/apps.html+25 −0internal/console/templates/check.html+6 −0internal/console/templates/home.html+10 −0internal/console/templates/layout.html+107 −0internal/console/templates/message.html+5 −0internal/console/view.go+145 −0internal/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) |
| + } |
| +} |