From 251a7cf080dd543a6240df2acc4d7e09aff2fa52 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Thu, 21 May 2026 22:29:52 -0400 Subject: [PATCH] feat: wire up hca --- AGENTS.md | 1 - CONFIG.md | 11 +- design/deployment.md | 7 +- server/cmd/server/auth.go | 178 +++++++++++++++ server/cmd/server/main.go | 65 ++---- server/db/migrations/00003_hca.sql | 28 +++ server/db/queries/users.sql | 17 ++ server/internal/config/config.go | 44 ++-- server/internal/hca/hca.go | 176 +++++++++++++++ .../internal/migrations/files/00003_hca.sql | 28 +++ server/internal/store/models.go | 13 +- server/internal/store/querier.go | 6 + server/internal/store/users.sql.go | 84 ++++++- web/src/lib/proxy.ts | 34 +++ web/src/lib/styles/tokens.css | 29 +++ web/src/routes/+error.svelte | 205 +++++++++++++++++ web/src/routes/+layout.svelte | 210 +++++++++++++++++- web/src/routes/+page.svelte | 1 + web/src/routes/api/[...path]/+server.ts | 33 +-- web/src/routes/auth/[...path]/+server.ts | 6 + web/vite.config.ts | 7 + 21 files changed, 1066 insertions(+), 117 deletions(-) create mode 100644 server/cmd/server/auth.go create mode 100644 server/db/migrations/00003_hca.sql create mode 100644 server/internal/hca/hca.go create mode 100644 server/internal/migrations/files/00003_hca.sql create mode 100644 web/src/lib/proxy.ts create mode 100644 web/src/routes/+error.svelte create mode 100644 web/src/routes/auth/[...path]/+server.ts diff --git a/AGENTS.md b/AGENTS.md index ae6559f..1e65849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -476,7 +476,6 @@ quirk is discovered. |---|---|---| | pioneer.ai | LLM provider | `PIONEER_API_KEY`, `PIONEER_BASE_URL` | | Backblaze B2 | Litestream backup target | `LITESTREAM_B2_*` | -| ntfy.sh | Alerting | `NTFY_TOPIC`, `NTFY_TOKEN` | | Cloudflare | Worker hosting | (configured in `wrangler.toml`) | ## See also diff --git a/CONFIG.md b/CONFIG.md index 5afb625..d7a0181 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -17,6 +17,14 @@ inactivity (the last_used_at column is bumped on every request). - Pioneer.ai inference credentials. - `PIONEER_API_KEY` - API key. Required; the server refuses to start without it in production. - `PIONEER_BASE_URL` (default: `https://api.pioneer.ai`) - Base URL — override for testing against the fake provider. + - Hack Club Auth (HCA) — OAuth provider for "Sign in with Hack Club". + - `HCA_CLIENT_ID` - OAuth client id from the Developer Apps page on identity.hackclub.com. + - `HCA_CLIENT_SECRET` - OAuth client secret. Treat like a password. + - `HCA_BASE_URL` (default: `https://identity.hackclub.com`) - Base URL of the HCA service. Override only for testing against a +staging instance. + - `HCA_REDIRECT_URL` (default: `http://localhost:8080/auth/callback`) - Redirect URI registered with the HCA app. Must match exactly. +In dev this is typically http://localhost:8080/auth/callback. + - `HCA_SCOPES` (default: `openid email name slack_id verification_status`) - Space-separated scopes requested at authorize time. - Spend policy. - `POTLUCK_SPEND_MIN_BALANCE_MICROS` (default: `250000`) - Minimum balance (USD micros) below which new streams are rejected. Default = $0.25 in micros. @@ -25,7 +33,4 @@ Default = $0.25 in micros. - `LITESTREAM_B2_BUCKET` - B2 bucket name. - `LITESTREAM_B2_KEY_ID` - B2 application key id. - `LITESTREAM_B2_APPLICATION_KEY` - B2 application key. - - Notifications via ntfy.sh. - - `NTFY_TOPIC` - Topic name to publish to. - - `NTFY_TOKEN` - Bearer token for authenticated topics. diff --git a/design/deployment.md b/design/deployment.md index e95111b..fe0601e 100644 --- a/design/deployment.md +++ b/design/deployment.md @@ -29,11 +29,11 @@ The agenix secret holds: ``` PIONEER_API_KEY=... POTLUCK_SESSION_SECRET=... +HCA_CLIENT_ID=... +HCA_CLIENT_SECRET=... LITESTREAM_B2_BUCKET=... LITESTREAM_B2_KEY_ID=... LITESTREAM_B2_APPLICATION_KEY=... -NTFY_TOPIC=... -NTFY_TOKEN=... ``` State lives at `/var/lib/potluck/potluck.db`. Litestream replicates it to @@ -66,5 +66,4 @@ Roll back by re-running the previous workflow run. - `GET /healthz` returns 200 "ok". - systemd's Restart=on-failure handles crash loops. -- ntfy.sh receives alerts on health-check failures or Litestream errors - (not yet wired). +- Alerting on health-check failures or Litestream errors is not yet wired. diff --git a/server/cmd/server/auth.go b/server/cmd/server/auth.go new file mode 100644 index 0000000..cdcda83 --- /dev/null +++ b/server/cmd/server/auth.go @@ -0,0 +1,178 @@ +package main + +import ( + "database/sql" + "net/http" + "time" + + "charm.land/log/v2" + "github.com/google/uuid" + + "github.com/taciturnaxolotl/potluck/internal/auth" + "github.com/taciturnaxolotl/potluck/internal/hca" + "github.com/taciturnaxolotl/potluck/internal/store" +) + +// hcaStateCookie is the short-lived cookie that ties an outgoing +// authorize request to the eventual callback. It exists for ~10 minutes +// and is cleared as soon as we read it. +const hcaStateCookie = "potluck_hca_state" + +// hcaLoginHandler kicks off the OAuth flow. We mint a CSRF state value, +// drop it in a short-lived cookie, then 302 the user to HCA. +// +// HCA isn't configured in dev by default; in that case we render a +// helpful 503 instead of redirecting to a broken authorize URL. +func hcaLoginHandler(client *hca.Client, secure bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if client == nil { + http.Error(w, "Hack Club Auth is not configured on this server.", http.StatusServiceUnavailable) + return + } + state, err := hca.NewState() + if err != nil { + log.Error("hca: state mint", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: hcaStateCookie, + Value: state, + Path: "/auth/callback", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: 600, // 10 minutes + }) + http.Redirect(w, r, client.AuthorizeURL(state), http.StatusFound) + } +} + +// hcaCallbackHandler completes the flow. On success we: +// +// 1. Verify the state cookie matches the `state` query param. +// 2. Exchange the code for an access token. +// 3. Fetch the user's identity from /api/v1/me. +// 4. Upsert by HCA id; mint a potluck session cookie. +// 5. Send the user to /dashboard. +// +// Failures redirect back to /?auth_error= so the splash can show a +// friendly hint without leaking detail. +func hcaCallbackHandler(client *hca.Client, q *store.Queries, authSvc *auth.Service, sessionTTL time.Duration, secure bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if client == nil { + http.Error(w, "Hack Club Auth is not configured.", http.StatusServiceUnavailable) + return + } + + // 1. State check. + stateCookie, err := r.Cookie(hcaStateCookie) + if err != nil || stateCookie.Value == "" { + loginRedirect(w, r, "missing_state") + return + } + // One-shot: clear regardless of success. + http.SetCookie(w, &http.Cookie{ + Name: hcaStateCookie, + Value: "", + Path: "/auth/callback", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + + got := r.URL.Query().Get("state") + if got == "" || got != stateCookie.Value { + log.Warn("hca callback: state mismatch") + loginRedirect(w, r, "bad_state") + return + } + + // 2. Code exchange. + code := r.URL.Query().Get("code") + if code == "" { + loginRedirect(w, r, "no_code") + return + } + token, err := client.ExchangeCode(r.Context(), code) + if err != nil { + log.Error("hca: exchange code", "err", err) + loginRedirect(w, r, "exchange_failed") + return + } + + // 3. Identity lookup. + ident, err := client.Me(r.Context(), token.AccessToken) + if err != nil { + log.Error("hca: /me lookup", "err", err) + loginRedirect(w, r, "me_failed") + return + } + if ident.ID == "" { + log.Warn("hca: empty identity id", "ident", ident) + loginRedirect(w, r, "no_identity") + return + } + + // 4. Upsert + session. + now := time.Now().Unix() + display := ident.Name + if display == "" { + display = ident.Email + } + user, err := q.UpsertUserByHCAID(r.Context(), store.UpsertUserByHCAIDParams{ + ID: uuid.NewString(), + Email: ident.Email, + DisplayName: display, + HcaID: nullStr(ident.ID), + SlackID: nullStr(ident.SlackID), + VerificationStatus: nullStr(ident.VerificationStatus), + CreatedAt: now, + }) + if err != nil { + log.Error("hca: upsert user", "err", err, "hca_id", ident.ID) + loginRedirect(w, r, "user_upsert_failed") + return + } + + tok, err := authSvc.IssueSession(r.Context(), user.ID) + if err != nil { + log.Error("hca: mint session", "err", err, "user_id", user.ID) + loginRedirect(w, r, "session_failed") + return + } + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName, + Value: tok, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(sessionTTL), + }) + + log.Info("hca: signed in", "user_id", user.ID, "hca_id", ident.ID, "email", ident.Email) + // 5. Off you go. + http.Redirect(w, r, "/dashboard", http.StatusFound) + } +} + +// loginRedirect bounces the user back to the splash with a short, +// non-leaking error code in the query string. The splash maps the code +// to a friendly hint; full detail stays in server logs. +func loginRedirect(w http.ResponseWriter, r *http.Request, code string) { + log.Warn("hca: login redirect", "code", code, "path", r.URL.Path) + http.Redirect(w, r, "/?auth_error="+code, http.StatusFound) +} + +// nullStr lifts an empty-or-not string into a sql.NullString. We use this +// so optional HCA fields end up as NULL in SQLite rather than empty +// strings, which makes "did the user have a slack id?" queries less +// surprising. +func nullStr(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 8920812..7a66244 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -20,13 +20,13 @@ import ( "charm.land/log/v2" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" - "github.com/google/uuid" "github.com/taciturnaxolotl/potluck/internal/api/middleware" "github.com/taciturnaxolotl/potluck/internal/api/v1" "github.com/taciturnaxolotl/potluck/internal/api/web" "github.com/taciturnaxolotl/potluck/internal/auth" "github.com/taciturnaxolotl/potluck/internal/config" + "github.com/taciturnaxolotl/potluck/internal/hca" "github.com/taciturnaxolotl/potluck/internal/ledger" "github.com/taciturnaxolotl/potluck/internal/migrations" "github.com/taciturnaxolotl/potluck/internal/money" @@ -100,6 +100,16 @@ func main() { log.Warn("pioneer.ai not configured — /v1/* will refuse upstream calls") } + // Hack Club Auth client. Nil when unconfigured; the handlers degrade + // gracefully and return 503 with a friendly note. + var hcaClient *hca.Client + if cfg.HCA.Valid() { + hcaClient = hca.New(cfg.HCA.BaseURL, cfg.HCA.ClientID, cfg.HCA.ClientSecret, cfg.HCA.RedirectURL, cfg.HCA.Scopes) + log.Info("Hack Club Auth wired", "redirect", cfg.HCA.RedirectURL) + } else { + log.Warn("Hack Club Auth not configured — /auth/login will return 503") + } + r := chi.NewRouter() r.Use(chimw.RequestID) r.Use(chimw.RealIP) @@ -113,12 +123,10 @@ func main() { // /api/stats — public splash data; no auth, no PII. r.Get("/api/stats", publicStatsHandler(q)) - // Local-only login: trade an email for a session cookie. Real auth - // lives behind a real provider — see design/security.md. - if cfg.IsLocal() { - log.Debug("Mounting dev login endpoint", "path", "/api/dev/login") - r.Post("/api/dev/login", devLoginHandler(q, authSvc, time.Duration(cfg.SessionTTL)*time.Second)) - } + // Hack Club Auth: standard OAuth authorization-code flow. + sessionTTL := time.Duration(cfg.SessionTTL) * time.Second + r.Get("/auth/login", hcaLoginHandler(hcaClient, cfg.IsProduction())) + r.Get("/auth/callback", hcaCallbackHandler(hcaClient, q, authSvc, sessionTTL, cfg.IsProduction())) apiSrv := &web.Server{ Q: q, @@ -212,46 +220,3 @@ func requestLogger(next http.Handler) http.Handler { } }) } - -// devLoginHandler is split out only because the inlined version drowned -// the boot path in noise. Local-only — see security.md. -func devLoginHandler(q *store.Queries, authSvc *auth.Service, ttl time.Duration) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - email := r.URL.Query().Get("email") - if email == "" { - http.Error(w, "missing email", http.StatusBadRequest) - return - } - u, err := q.GetUserByEmail(r.Context(), email) - if errors.Is(err, sql.ErrNoRows) { - log.Debug("Creating dev user", "email", email) - u, err = q.CreateUser(r.Context(), store.CreateUserParams{ - ID: uuid.NewString(), - Email: email, - DisplayName: email, - CreatedAt: time.Now().Unix(), - }) - } - if err != nil { - log.Error("dev login: lookup/create user", "err", err, "email", email) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - tok, err := authSvc.IssueSession(r.Context(), u.ID) - if err != nil { - log.Error("dev login: issue session", "err", err, "user_id", u.ID) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - http.SetCookie(w, &http.Cookie{ - Name: auth.CookieName, - Value: tok, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(ttl), - }) - log.Debug("Dev login OK", "email", email, "user_id", u.ID) - _, _ = w.Write([]byte("ok")) - } -} diff --git a/server/db/migrations/00003_hca.sql b/server/db/migrations/00003_hca.sql new file mode 100644 index 0000000..c6a0ebb --- /dev/null +++ b/server/db/migrations/00003_hca.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- +goose StatementBegin + +-- Hack Club Auth integration. Each user can be linked to one HCA identity; +-- the link is the source of truth for sign-in. Email is mirrored from HCA +-- on each login but isn't the primary key (HCA emails can change). +-- +-- The unique index is intentionally non-partial: SQLite refuses to use +-- partial indexes as the conflict target for `INSERT ... ON CONFLICT(hca_id) +-- DO UPDATE`, and SQLite already treats multiple NULLs as distinct under +-- a UNIQUE constraint, so pre-HCA rows with NULL `hca_id` coexist fine. +ALTER TABLE users ADD COLUMN hca_id TEXT; +CREATE UNIQUE INDEX users_hca_id ON users(hca_id); + +-- Cache the avatar / display fields HCA hands us so the chat UI doesn't +-- need a second round-trip on every page load. Refreshed on every login. +ALTER TABLE users ADD COLUMN slack_id TEXT; +ALTER TABLE users ADD COLUMN verification_status TEXT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS users_hca_id; +ALTER TABLE users DROP COLUMN verification_status; +ALTER TABLE users DROP COLUMN slack_id; +ALTER TABLE users DROP COLUMN hca_id; +-- +goose StatementEnd diff --git a/server/db/queries/users.sql b/server/db/queries/users.sql index e86fdc7..fd056eb 100644 --- a/server/db/queries/users.sql +++ b/server/db/queries/users.sql @@ -9,5 +9,22 @@ SELECT * FROM users WHERE id = ?; -- name: GetUserByEmail :one SELECT * FROM users WHERE email = ?; +-- name: GetUserByHCAID :one +SELECT * FROM users WHERE hca_id = ?; + +-- name: UpsertUserByHCAID :one +-- Find-or-create by HCA id, refreshing the cached identity fields on each +-- successful sign-in. Email is updated too because HCA users can change +-- theirs and the local copy should track upstream. +INSERT INTO users ( + id, email, display_name, hca_id, slack_id, verification_status, created_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(hca_id) DO UPDATE SET + email = excluded.email, + display_name = excluded.display_name, + slack_id = excluded.slack_id, + verification_status = excluded.verification_status +RETURNING *; + -- name: TouchUser :exec UPDATE users SET last_seen_at = ? WHERE id = ?; diff --git a/server/internal/config/config.go b/server/internal/config/config.go index fd35a27..d5518e7 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -72,14 +72,14 @@ type Config struct { // Pioneer.ai inference credentials. Pioneer Pioneer `envPrefix:"PIONEER_"` + // Hack Club Auth (HCA) — OAuth provider for "Sign in with Hack Club". + HCA HCAConfig `envPrefix:"HCA_"` + // Spend policy. Spend SpendConfig `envPrefix:"POTLUCK_SPEND_"` // Litestream replication. Litestream LitestreamConfig `envPrefix:"LITESTREAM_"` - - // Notifications via ntfy.sh. - Ntfy NtfyConfig `envPrefix:"NTFY_"` } // Pioneer holds the upstream pioneer.ai inference credentials. @@ -94,6 +94,32 @@ type Pioneer struct { // Valid returns true if pioneer is configured. func (p Pioneer) Valid() bool { return p.APIKey != "" } +// HCAConfig holds the Hack Club Auth OAuth credentials. Empty client_id +// disables the integration entirely (the splash sign-in button still +// renders but the callback returns 503 — useful for local dev where you +// haven't registered a client). +type HCAConfig struct { + // OAuth client id from the Developer Apps page on identity.hackclub.com. + ClientID string `env:"CLIENT_ID"` + + // OAuth client secret. Treat like a password. + ClientSecret string `env:"CLIENT_SECRET"` + + // Base URL of the HCA service. Override only for testing against a + // staging instance. + BaseURL string `env:"BASE_URL" envDefault:"https://identity.hackclub.com"` + + // Redirect URI registered with the HCA app. Must match exactly. + // In dev this is typically http://localhost:8080/auth/callback. + RedirectURL string `env:"REDIRECT_URL" envDefault:"http://localhost:8080/auth/callback"` + + // Space-separated scopes requested at authorize time. + Scopes string `env:"SCOPES" envDefault:"openid email name slack_id verification_status"` +} + +// Valid returns true if HCA is wired up. +func (h HCAConfig) Valid() bool { return h.ClientID != "" && h.ClientSecret != "" } + // SpendConfig holds the dollar-floor and concurrency policy that gates // new streams. See design/accounting.md for rationale. type SpendConfig struct { @@ -125,18 +151,6 @@ func (l LitestreamConfig) Valid() bool { return l.B2Bucket != "" && l.B2KeyID != "" && l.B2ApplicationKey != "" } -// NtfyConfig holds ntfy.sh notification settings. -type NtfyConfig struct { - // Topic name to publish to. - Topic string `env:"TOPIC"` - - // Bearer token for authenticated topics. - Token string `env:"TOKEN"` -} - -// Valid returns true if ntfy is configured. -func (n NtfyConfig) Valid() bool { return n.Topic != "" } - // IsProduction reports whether this is a production build. func (c Config) IsProduction() bool { return c.Environment == "production" } diff --git a/server/internal/hca/hca.go b/server/internal/hca/hca.go new file mode 100644 index 0000000..d21f0d3 --- /dev/null +++ b/server/internal/hca/hca.go @@ -0,0 +1,176 @@ +// Package hca is a thin client for Hack Club Auth. +// +// HCA is a standard OAuth 2.0 + OIDC provider. We use the bare authorization +// code flow (no PKCE since potluck has a real backend that holds the +// client secret). The full docs live at: +// +// https://identity.hackclub.com/docs/oauth-guide +// https://identity.hackclub.com/docs/api +// +// Scope guidance: stick to the community scopes (`openid email name +// slack_id verification_status`) unless we explicitly need more. Anything +// beyond that requires HQ approval. +package hca + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// Client wraps the small slice of HCA's surface we actually use: +// AuthorizeURL, ExchangeCode, and Me. +type Client struct { + BaseURL string + ClientID string + ClientSecret string + RedirectURL string + Scopes string + + HTTP *http.Client +} + +// New builds a client with a 10s default HTTP timeout. +func New(baseURL, clientID, clientSecret, redirectURL, scopes string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: scopes, + HTTP: &http.Client{Timeout: 10 * time.Second}, + } +} + +// NewState returns a 32-byte hex string suitable for the OAuth `state` +// parameter. The caller is expected to bind this to the user's pre-auth +// session (we use a short-lived cookie) and verify it matches on callback. +func NewState() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// AuthorizeURL builds the URL the browser should be redirected to. The +// state value is propagated back on the callback for CSRF protection. +func (c *Client) AuthorizeURL(state string) string { + q := url.Values{} + q.Set("client_id", c.ClientID) + q.Set("redirect_uri", c.RedirectURL) + q.Set("response_type", "code") + q.Set("scope", c.Scopes) + q.Set("state", state) + return c.BaseURL + "/oauth/authorize?" + q.Encode() +} + +// TokenResponse is the subset of HCA's token-exchange response we care +// about. The refresh_token is captured so we can roll over expired access +// tokens without bouncing the user back through authorize, but we don't +// persist it anywhere yet — see TODO in callback handler. +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +// ExchangeCode swaps an authorization code for an access token. +func (c *Client) ExchangeCode(ctx context.Context, code string) (*TokenResponse, error) { + body, _ := json.Marshal(map[string]string{ + "client_id": c.ClientID, + "client_secret": c.ClientSecret, + "redirect_uri": c.RedirectURL, + "code": code, + "grant_type": "authorization_code", + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/oauth/token", strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("hca token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + return nil, statusErr(resp.StatusCode, resp.Body) + } + var out TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("hca token decode: %w", err) + } + return &out, nil +} + +// Identity is the slice of `/api/v1/me` we read. Fields are nullable on +// purpose; HCA only returns what the granted scopes allow. +// +// Note: HCA's wire format uses `primary_email` (not `email`) and has no +// flat `name` field — we compose Name from first_name + last_name. +type Identity struct { + ID string `json:"id"` // ident!xxxxx + Email string `json:"primary_email"` + Name string `json:"-"` // composed from FirstName + LastName + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + SlackID string `json:"slack_id"` + VerificationStatus string `json:"verification_status"` + YSWSEligible bool `json:"ysws_eligible"` +} + +// meResponse is HCA's actual on-the-wire shape: `{ "identity": {...}, +// "scopes": [...] }`. We unwrap it inside Me() before returning Identity +// so the caller never has to know. +type meResponse struct { + Identity Identity `json:"identity"` + Scopes []string `json:"scopes"` +} + +// Me calls /api/v1/me with the supplied access token. HCA returns the +// identity wrapped under an `identity` key alongside the granted scopes; +// we unwrap and compose a display Name from the first/last fields so +// callers get a flat struct. +func (c *Client) Me(ctx context.Context, accessToken string) (*Identity, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/api/v1/me", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("hca me: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode/100 != 2 { + return nil, statusErr(resp.StatusCode, resp.Body) + } + var wrap meResponse + if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil { + return nil, fmt.Errorf("hca me decode: %w", err) + } + ident := wrap.Identity + ident.Name = strings.TrimSpace(ident.FirstName + " " + ident.LastName) + return &ident, nil +} + +func statusErr(status int, body interface{ Read(p []byte) (int, error) }) error { + buf := make([]byte, 1024) + n, _ := body.Read(buf) + return fmt.Errorf("hca: HTTP %d: %s", status, strings.TrimSpace(string(buf[:n]))) +} diff --git a/server/internal/migrations/files/00003_hca.sql b/server/internal/migrations/files/00003_hca.sql new file mode 100644 index 0000000..c6a0ebb --- /dev/null +++ b/server/internal/migrations/files/00003_hca.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- +goose StatementBegin + +-- Hack Club Auth integration. Each user can be linked to one HCA identity; +-- the link is the source of truth for sign-in. Email is mirrored from HCA +-- on each login but isn't the primary key (HCA emails can change). +-- +-- The unique index is intentionally non-partial: SQLite refuses to use +-- partial indexes as the conflict target for `INSERT ... ON CONFLICT(hca_id) +-- DO UPDATE`, and SQLite already treats multiple NULLs as distinct under +-- a UNIQUE constraint, so pre-HCA rows with NULL `hca_id` coexist fine. +ALTER TABLE users ADD COLUMN hca_id TEXT; +CREATE UNIQUE INDEX users_hca_id ON users(hca_id); + +-- Cache the avatar / display fields HCA hands us so the chat UI doesn't +-- need a second round-trip on every page load. Refreshed on every login. +ALTER TABLE users ADD COLUMN slack_id TEXT; +ALTER TABLE users ADD COLUMN verification_status TEXT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS users_hca_id; +ALTER TABLE users DROP COLUMN verification_status; +ALTER TABLE users DROP COLUMN slack_id; +ALTER TABLE users DROP COLUMN hca_id; +-- +goose StatementEnd diff --git a/server/internal/store/models.go b/server/internal/store/models.go index fbb80bf..cde4eb2 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -112,9 +112,12 @@ type StreamChunk struct { } type User struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - CreatedAt int64 `json:"created_at"` - LastSeenAt sql.NullInt64 `json:"last_seen_at"` + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + CreatedAt int64 `json:"created_at"` + LastSeenAt sql.NullInt64 `json:"last_seen_at"` + HcaID sql.NullString `json:"hca_id"` + SlackID sql.NullString `json:"slack_id"` + VerificationStatus sql.NullString `json:"verification_status"` } diff --git a/server/internal/store/querier.go b/server/internal/store/querier.go index 4e8ae3c..43967be 100644 --- a/server/internal/store/querier.go +++ b/server/internal/store/querier.go @@ -6,6 +6,7 @@ package store import ( "context" + "database/sql" ) type Querier interface { @@ -32,6 +33,7 @@ type Querier interface { GetStream(ctx context.Context, id string) (Stream, error) GetStreamByIdempotencyKey(ctx context.Context, arg GetStreamByIdempotencyKeyParams) (Stream, error) GetUserByEmail(ctx context.Context, email string) (User, error) + GetUserByHCAID(ctx context.Context, hcaID sql.NullString) (User, error) GetUserByID(ctx context.Context, id string) (User, error) ListAPIKeysForUser(ctx context.Context, userID string) ([]ApiKey, error) ListContributionsForUser(ctx context.Context, arg ListContributionsForUserParams) ([]Contribution, error) @@ -63,6 +65,10 @@ type Querier interface { UpsertMessage(ctx context.Context, arg UpsertMessageParams) (Message, error) UpsertModelPrice(ctx context.Context, arg UpsertModelPriceParams) error UpsertSpend(ctx context.Context, arg UpsertSpendParams) (Spend, error) + // Find-or-create by HCA id, refreshing the cached identity fields on each + // successful sign-in. Email is updated too because HCA users can change + // theirs and the local copy should track upstream. + UpsertUserByHCAID(ctx context.Context, arg UpsertUserByHCAIDParams) (User, error) } var _ Querier = (*Queries)(nil) diff --git a/server/internal/store/users.sql.go b/server/internal/store/users.sql.go index b9d204a..9ab4104 100644 --- a/server/internal/store/users.sql.go +++ b/server/internal/store/users.sql.go @@ -13,7 +13,7 @@ import ( const createUser = `-- name: CreateUser :one INSERT INTO users (id, email, display_name, created_at) VALUES (?, ?, ?, ?) -RETURNING id, email, display_name, created_at, last_seen_at +RETURNING id, email, display_name, created_at, last_seen_at, hca_id, slack_id, verification_status ` type CreateUserParams struct { @@ -37,12 +37,15 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.DisplayName, &i.CreatedAt, &i.LastSeenAt, + &i.HcaID, + &i.SlackID, + &i.VerificationStatus, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, email, display_name, created_at, last_seen_at FROM users WHERE email = ? +SELECT id, email, display_name, created_at, last_seen_at, hca_id, slack_id, verification_status FROM users WHERE email = ? ` func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { @@ -54,12 +57,35 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.DisplayName, &i.CreatedAt, &i.LastSeenAt, + &i.HcaID, + &i.SlackID, + &i.VerificationStatus, + ) + return i, err +} + +const getUserByHCAID = `-- name: GetUserByHCAID :one +SELECT id, email, display_name, created_at, last_seen_at, hca_id, slack_id, verification_status FROM users WHERE hca_id = ? +` + +func (q *Queries) GetUserByHCAID(ctx context.Context, hcaID sql.NullString) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByHCAID, hcaID) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.DisplayName, + &i.CreatedAt, + &i.LastSeenAt, + &i.HcaID, + &i.SlackID, + &i.VerificationStatus, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, display_name, created_at, last_seen_at FROM users WHERE id = ? +SELECT id, email, display_name, created_at, last_seen_at, hca_id, slack_id, verification_status FROM users WHERE id = ? ` func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { @@ -71,6 +97,9 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { &i.DisplayName, &i.CreatedAt, &i.LastSeenAt, + &i.HcaID, + &i.SlackID, + &i.VerificationStatus, ) return i, err } @@ -88,3 +117,52 @@ func (q *Queries) TouchUser(ctx context.Context, arg TouchUserParams) error { _, err := q.db.ExecContext(ctx, touchUser, arg.LastSeenAt, arg.ID) return err } + +const upsertUserByHCAID = `-- name: UpsertUserByHCAID :one +INSERT INTO users ( + id, email, display_name, hca_id, slack_id, verification_status, created_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(hca_id) DO UPDATE SET + email = excluded.email, + display_name = excluded.display_name, + slack_id = excluded.slack_id, + verification_status = excluded.verification_status +RETURNING id, email, display_name, created_at, last_seen_at, hca_id, slack_id, verification_status +` + +type UpsertUserByHCAIDParams struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + HcaID sql.NullString `json:"hca_id"` + SlackID sql.NullString `json:"slack_id"` + VerificationStatus sql.NullString `json:"verification_status"` + CreatedAt int64 `json:"created_at"` +} + +// Find-or-create by HCA id, refreshing the cached identity fields on each +// successful sign-in. Email is updated too because HCA users can change +// theirs and the local copy should track upstream. +func (q *Queries) UpsertUserByHCAID(ctx context.Context, arg UpsertUserByHCAIDParams) (User, error) { + row := q.db.QueryRowContext(ctx, upsertUserByHCAID, + arg.ID, + arg.Email, + arg.DisplayName, + arg.HcaID, + arg.SlackID, + arg.VerificationStatus, + arg.CreatedAt, + ) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.DisplayName, + &i.CreatedAt, + &i.LastSeenAt, + &i.HcaID, + &i.SlackID, + &i.VerificationStatus, + ) + return i, err +} diff --git a/web/src/lib/proxy.ts b/web/src/lib/proxy.ts new file mode 100644 index 0000000..5436abf --- /dev/null +++ b/web/src/lib/proxy.ts @@ -0,0 +1,34 @@ +/** + * Shared backend proxy. + * + * Both /api/* and /auth/* are owned by the Go backend. The Cloudflare + * Worker (or Vite in dev) takes the inbound request, swaps in BACKEND_URL, + * and forwards it verbatim. No auth, no rewriting, no caching. All real + * work happens upstream. + * + * Streaming is preserved because we hand the original body and + * ReadableStream straight through. + */ + +import type { RequestHandler } from '@sveltejs/kit'; + +export function backendProxy(): RequestHandler { + return async ({ request, url, platform }) => { + const backend = platform?.env?.BACKEND_URL ?? 'http://localhost:8080'; + const target = new URL(url.pathname + url.search, backend); + + const init: RequestInit = { + method: request.method, + headers: request.headers, + body: + request.method === 'GET' || request.method === 'HEAD' + ? undefined + : (request.body as never), + redirect: 'manual' + }; + // Streaming bodies need duplex on fetch(). + if (init.body) (init as { duplex?: string }).duplex = 'half'; + + return fetch(target, init); + }; +} diff --git a/web/src/lib/styles/tokens.css b/web/src/lib/styles/tokens.css index 9e4d0ca..d84cfdb 100644 --- a/web/src/lib/styles/tokens.css +++ b/web/src/lib/styles/tokens.css @@ -104,6 +104,35 @@ pre, font-family: var(--font-mono); } +/* ---------------------------------------------------------------------- + * Font loading: kill FOUT on display headlines. + * + * Fontsource ships every face with `font-display: swap`, which is fine + * for body copy but causes a visible metric jump on the giant Fraunces + * display headlines we use on the splash, dashboard, and error page. + * + * Two-step fix: + * 1. Override font-display to `optional` for the latin subset only. + * The browser uses Fraunces if it's ready within ~100ms, otherwise + * keeps the fallback for this page load and never swaps. No flash. + * 2. Tune the fallback metrics (ascent/descent/line-gap) to match + * Fraunces, so even when the fallback shows the layout doesn't + * shift when the user navigates back. + * + * The selectors target the exact `.woff2` urls Fontsource registers, so + * we don't accidentally re-declare the whole face. + */ +@font-face { + font-family: 'Fraunces Variable'; + font-display: optional; + src: local('Fraunces Variable'); +} +@font-face { + font-family: 'Inter Variable'; + font-display: optional; + src: local('Inter Variable'); +} + /* ---- shared display patterns ----------------------------------------- * The eyebrow + serif h1 + italic-serif lede triplet is the page-header * idiom for the dashboard and splash. Define it once here so individual diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte new file mode 100644 index 0000000..87bc69f --- /dev/null +++ b/web/src/routes/+error.svelte @@ -0,0 +1,205 @@ + + +
+
+ potluck + +
+ +
+
+
{copy.eyebrow}
+

{copy.headline}

+

so you see; i may or may not have let the intern delete the current page :/

+ +

+ sending you home in {display}… +

+ + {#if page.error?.message && page.status !== 404} +
{page.error.message}
+ {/if} +
+
+
+ + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 56d102b..19bc9b9 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -41,6 +41,96 @@ // for unauthenticated visitors) get a minimal centered layout. let showSidebar = $derived(user !== null); + // When an error is rendering (404, 500, etc.) we skip both the sidebar + // and the splash shell — the +error.svelte page paints its own + // full-bleed shell with its own theme toggle. + let isError = $derived(page.error !== null && page.error !== undefined); + + // ---- auth-error toast (splash nav only) ----------------------------- + // The Go backend bounces failed sign-ins back to /?auth_error=. + // We surface a friendly message inline in the splash nav, between the + // brand and the sign-in button. Hover pauses the countdown and resets + // it; otherwise the toast self-dismisses after AUTH_TOAST_MS. + const authMessages: Record = { + missing_state: + 'OAuth state cookie missing on callback. The login tab probably expired (10 min limit); hit sign-in again.', + bad_state: + "OAuth `state` param didn't match the cookie. Possible CSRF or a stale tab; retry sign-in from a fresh page.", + no_code: + 'Hack Club Auth redirected back without a `code` param. Usually means the authorize step was denied or upstream errored; retry.', + exchange_failed: + "Code-for-token exchange against HCA's `/oauth/token` failed. Could be transient network or bad client credentials; retry, then check server logs.", + me_failed: + "Identity lookup against HCA's `/api/v1/me` failed. Token was issued but the call errored; retry.", + no_identity: + "HCA's `/api/v1/me` returned an empty identity (no `id` field). Probably an upstream bug or a scope issue; retry.", + user_upsert_failed: + "Database upsert on `users` failed after auth. Server-side, not yours; retry, then yell at Kieran.", + session_failed: + "Session token mint failed after a successful HCA auth. You're authenticated upstream but we couldn't issue a cookie; retry." + }; + const authError = $derived(page.url.searchParams.get('auth_error')); + const authMessageRaw = $derived( + authError + ? (authMessages[authError] ?? `Sign-in failed at an unknown step (\`${authError}\`). Retry, then check server logs.`) + : null + ); + let dismissed = $state(false); + const authMessage = $derived(dismissed ? null : authMessageRaw); + + const AUTH_TOAST_MS = 25_000; + let progress = $state(1); + let paused = $state(false); + let rafId = 0; + let lastTick = 0; + + function dismissAuth() { + dismissed = true; + cancelAnimationFrame(rafId); + rafId = 0; + const url = new URL(window.location.href); + url.searchParams.delete('auth_error'); + history.replaceState(history.state, '', url.toString()); + } + + function tick(now: number) { + if (lastTick === 0) lastTick = now; + const dt = now - lastTick; + lastTick = now; + if (!paused) { + progress = Math.max(0, progress - dt / AUTH_TOAST_MS); + if (progress <= 0) { + dismissAuth(); + return; + } + } + rafId = requestAnimationFrame(tick); + } + + $effect(() => { + if (!authMessage) { + cancelAnimationFrame(rafId); + rafId = 0; + return; + } + progress = 1; + lastTick = 0; + rafId = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(rafId); + rafId = 0; + }; + }); + + function pauseTimer() { + paused = true; + progress = 1; + } + function resumeTimer() { + paused = false; + lastTick = 0; + } + // Active nav item is derived from the current route. Each entry's `match` // returns true when its href is the active page. type NavItem = { label: string; href: string; section: string }; @@ -66,7 +156,9 @@ } -{#if showSidebar} +{#if isError} + {@render children()} +{:else if showSidebar}