Compare commits

...
4 Commits
Author SHA1 Message Date
dcrubro a7831cd1fe remove unndeeded function 2026-09-06 19:37:45 +02:00
dcrubro 629d515911 overrides 2026-09-06 19:22:47 +02:00
dcrubro 2dc9a30644 api calls 2026-09-06 19:14:59 +02:00
dcrubro 13627a369a fix namings 2026-09-06 18:28:52 +02:00
17 changed files with 1592 additions and 52 deletions
+22 -3
View File
@@ -1,16 +1,35 @@
.PHONY: build run gen test tidy BINARY := bin/hammyd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X main.version=$(VERSION)
.PHONY: build run gen test race cover lint tidy clean
build: build:
go build -o bin/hammyd ./cmd/hammyd go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/hammyd
run: run:
go run ./cmd/hammyd go run -ldflags "$(LDFLAGS)" ./cmd/hammyd serve
# Regenerate internal/db from queries/*.sql. Run after touching a migration or
# a query; forgetting turns a compile error into a runtime scan error.
gen: gen:
sqlc generate sqlc generate
test: test:
go test ./... go test ./...
race:
go test -race ./...
cover:
go test -cover ./...
lint:
go vet ./...
gofmt -l .
tidy: tidy:
go mod tidy go mod tidy
clean:
rm -rf bin
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.dcrubro.com/dcrubro/hammy-backend/internal/apikey"
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
)
// runKeygen issues an API key from the command line. This is how you get the
// first key, before there is any self-service registration, and how you issue
// yourself something to test against.
//
// hammyd keygen -email [email protected] -scopes reference,callsign
func runKeygen(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
email := fs.String("email", "", "owner email (required); created if new")
callsign := fs.String("callsign", "", "owner callsign (optional, uppercase)")
label := fs.String("label", "", "what this key is for, shown in listings")
scopes := fs.String("scopes", "reference", "comma-separated scopes")
tier := fs.String("tier", "default", "quota tier: default, verified, trusted, internal")
env := fs.String("env", "live", "live or test")
expires := fs.Duration("expires", 0, "optional lifetime, e.g. 720h. Zero means no expiry")
if err := fs.Parse(args); err != nil {
return err
}
if *email == "" {
fs.Usage()
return errors.New("keygen: -email is required")
}
// The core.email domain requires lowercase and core.callsign requires
// uppercase. Normalise here so the CLI is forgiving and the database stays
// canonical.
normEmail := strings.ToLower(strings.TrimSpace(*email))
normCall := strings.ToUpper(strings.TrimSpace(*callsign))
dsn := os.Getenv("HAMMY_DSN")
if dsn == "" {
return errors.New("keygen: HAMMY_DSN is not set")
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return fmt.Errorf("keygen: connect: %w", err)
}
defer pool.Close()
q := db.New(pool)
owner, err := q.OwnerByEmail(ctx, normEmail)
if errors.Is(err, pgx.ErrNoRows) {
var callsignArg any
if normCall != "" {
callsignArg = normCall
}
created, cerr := q.CreateOwner(ctx, db.CreateOwnerParams{
Email: normEmail,
Callsign: callsignArg,
})
if cerr != nil {
return fmt.Errorf("keygen: creating owner: %w", cerr)
}
owner.ID = created.ID
fmt.Printf("created owner %d for %s\n", owner.ID, normEmail)
} else if err != nil {
return fmt.Errorf("keygen: looking up owner: %w", err)
}
gen, err := apikey.Generate(apikey.Environment(*env))
if err != nil {
return fmt.Errorf("keygen: %w", err)
}
var expiresAt *time.Time
if *expires > 0 {
t := time.Now().Add(*expires)
expiresAt = &t
}
scopeList := splitScopes(*scopes)
key, err := q.CreateKey(ctx, db.CreateKeyParams{
OwnerID: owner.ID,
KeyHash: gen.Hash,
KeyPrefix: gen.Display,
Label: nullableString(*label),
Scopes: scopeList,
QuotaTier: *tier,
ExpiresAt: db.Timestamptz(expiresAt),
})
if err != nil {
// The keys_scopes_known CHECK rejects unknown scopes, which is the
// most likely failure here and worth naming.
return fmt.Errorf("keygen: creating key (check scopes against migrations/003_api.sql): %w", err)
}
fmt.Println()
fmt.Println("Key issued. This is the only time it will be shown.")
fmt.Println()
fmt.Printf(" %s\n", gen.Plaintext)
fmt.Println()
fmt.Printf(" id %d\n", key.ID)
fmt.Printf(" owner %d (%s)\n", owner.ID, normEmail)
fmt.Printf(" scopes %s\n", strings.Join(scopeList, ", "))
fmt.Printf(" tier %s\n", key.QuotaTier)
if expiresAt != nil {
fmt.Printf(" expires %s\n", expiresAt.UTC().Format(time.RFC3339))
} else {
fmt.Printf(" expires never\n")
}
fmt.Println()
fmt.Println("Test it with:")
fmt.Printf(" curl -H 'Authorization: Bearer %s' http://localhost:8080/v1/whoami\n", gen.Plaintext)
fmt.Println()
return nil
}
func splitScopes(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func nullableString(s string) *string {
if s == "" {
return nil
}
return &s
}
+235 -16
View File
@@ -1,37 +1,256 @@
// cmd/hammyd/main.go
package main package main
import ( import (
"context" "context"
"log" "errors"
"fmt"
"log/slog"
"net/http"
"os" "os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"git.dcrubro.com/dcrubro/hammy-backend/internal/api"
"git.dcrubro.com/dcrubro/hammy-backend/internal/config"
"git.dcrubro.com/dcrubro/hammy-backend/internal/db"
"git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit"
) )
func main() { // version is stamped at build time:
ctx := context.Background() // go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" ./cmd/hammyd
var version = "dev"
dsn := os.Getenv("HAMMY_DSN") func main() {
if dsn == "" { // All real work happens in run() so that deferred cleanup actually runs.
log.Fatal("HAMMY_DSN is not set!") // os.Exit skips defers, so calling it anywhere but here leaks the pool and
// drops in-flight requests.
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "hammyd: %v\n", err)
os.Exit(1)
}
} }
pool, err := pgxpool.New(ctx, dsn) func run() error {
// NotifyContext cancels on SIGINT/SIGTERM, which is what gives the server
// a chance to finish in-flight requests instead of being killed mid-write.
ctx, stop := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer stop()
args := os.Args[1:]
cmd := "serve"
if len(args) > 0 {
cmd, args = args[0], args[1:]
}
switch cmd {
case "serve":
return runServe(ctx, args)
case "keygen":
return runKeygen(ctx, args)
case "version":
fmt.Println(version)
return nil
case "help", "-h", "--help":
usage()
return nil
default:
usage()
return fmt.Errorf("unknown command %q", cmd)
}
}
func usage() {
fmt.Fprintf(os.Stderr, `hammyd %s
Usage:
hammyd serve Run the API server (default)
hammyd keygen -email … Issue an API key
hammyd version
Configuration comes from the environment:
HAMMY_DSN Postgres connection string (required)
HAMMY_ADDR Listen address (default 127.0.0.1:8080)
HAMMY_REDIS_ADDR Redis for shared rate limiting (optional)
HAMMY_MAX_CONNS Postgres pool size (default 10)
HAMMY_LOG_LEVEL debug, info, warn, error (default info)
HAMMY_LOG_JSON true for structured logs
HAMMY_ENDUSER_HEADER Header carrying the end-user id (default X-Hammy-User)
`, version)
}
func runServe(ctx context.Context, _ []string) error {
cfg, err := config.Load()
if err != nil { if err != nil {
log.Fatalf("connect: %v", err) return err
}
logger := newLogger(cfg)
slog.SetDefault(logger)
logger.Info("starting", "version", version, "config", cfg.Redacted())
// ---- Postgres ---------------------------------------------------------
poolCfg, err := pgxpool.ParseConfig(cfg.DSN)
if err != nil {
return fmt.Errorf("parsing DSN: %w", err)
}
// Postgres forks a backend process per connection, so the pool size is a
// real resource on the server, not just a client-side knob.
poolCfg.MaxConns = cfg.MaxConns
poolCfg.MaxConnLifetime = time.Hour
poolCfg.MaxConnIdleTime = 15 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return fmt.Errorf("connecting to postgres: %w", err)
} }
defer pool.Close() defer pool.Close()
if err := pool.Ping(ctx); err != nil { // Fail at startup rather than on the first request. A backend that boots
log.Fatalf("ping: %v", err) // happily and 500s on everything is much harder to diagnose.
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
return fmt.Errorf("pinging postgres: %w", err)
} }
var n int logger.Info("postgres connected", "max_conns", cfg.MaxConns)
err = pool.QueryRow(ctx, "SELECT count(*) FROM core.users").Scan(&n)
if err != nil { // ---- Redis, or not ----------------------------------------------------
log.Fatalf("query: %v", err)
var limiter ratelimit.Limiter
if cfg.RedisAddr == "" {
// A single self-hosted instance does not need Redis to have working
// rate limits. Several instances do: each process would keep its own
// counters, so N processes allow N times the limit.
logger.Warn("HAMMY_REDIS_ADDR is unset, using an in-process limiter. " +
"Correct for one instance, wrong for several.")
mem := ratelimit.NewMemory()
limiter = mem
go sweepPeriodically(ctx, mem, time.Minute, logger)
} else {
rdb := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, DB: cfg.RedisDB})
defer rdb.Close()
if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("connecting to redis: %w", err)
} }
log.Printf("connected, %d users", n) logger.Info("redis connected", "addr", cfg.RedisAddr)
limiter = ratelimit.NewRedis(ratelimit.GoRedis{Client: rdb})
}
// ---- HTTP -------------------------------------------------------------
queries := db.New(pool)
router := &api.Router{
Auth: &api.Authenticator{
Keys: api.PgKeyStore{Q: queries},
Limiter: limiter,
Logger: logger,
EndUserHeader: cfg.EndUserHeader,
},
Logger: logger,
Version: version,
Ready: func() error {
// Readiness checks dependencies; liveness deliberately does not.
c, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return pool.Ping(c)
},
}
srv := &http.Server{
Addr: cfg.Addr,
Handler: router.Handler(),
// ReadHeaderTimeout is the one that matters: without it, a client can
// hold a connection open by dribbling headers forever (Slowloris).
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", cfg.Addr)
// ErrServerClosed is the normal result of Shutdown, not a failure.
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return fmt.Errorf("listening: %w", err)
case <-ctx.Done():
logger.Info("shutdown signal received, draining")
}
// A FRESH context: ctx is already cancelled, and passing it to Shutdown
// would abort in-flight requests immediately, which is the opposite of
// what a graceful shutdown is for.
shutdownCtx, cancelShutdown := context.WithTimeout(
context.Background(), cfg.ShutdownTimeout)
defer cancelShutdown()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
logger.Info("stopped cleanly")
return nil
}
// sweepPeriodically drops expired windows from the in-process limiter. Without
// it the map grows for every key and end user ever seen.
func sweepPeriodically(ctx context.Context, m *ratelimit.Memory, every time.Duration, logger *slog.Logger) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n := m.Sweep(); n > 0 {
logger.Debug("swept rate limit windows", "removed", n)
}
}
}
}
func newLogger(cfg config.Config) *slog.Logger {
opts := &slog.HandlerOptions{Level: cfg.LogLevel}
if cfg.LogJSON {
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
} }
+7 -1
View File
@@ -7,7 +7,7 @@ import (
) )
// Error is the single shape every failure takes. Clients get a stable machine // Error is the single shape every failure takes. Clients get a stable machine
// readable code plus a human message. A self-hosted bot instance can branch on // readable code plus a human message; a self-hosted bot instance can branch on
// the code without parsing prose. // the code without parsing prose.
type Error struct { type Error struct {
Code string `json:"code"` Code string `json:"code"`
@@ -46,6 +46,12 @@ func WriteError(w http.ResponseWriter, status int, code, message string) {
} }
} }
// writeErrorBody writes just the envelope, for callers that have already sent
// their own status code and headers.
func writeErrorBody(w http.ResponseWriter, code, message string) error {
return json.NewEncoder(w).Encode(errorEnvelope{Error{Code: code, Message: message}})
}
// WriteJSON sends a success payload. // WriteJSON sends a success payload.
func WriteJSON(w http.ResponseWriter, status int, v any) { func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
+149
View File
@@ -0,0 +1,149 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"runtime/debug"
"time"
)
type requestIDKey struct{}
// RequestIDFrom returns the id assigned by WithRequestID, or "".
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
// WithRequestID tags each request so a log line can be tied to a client report.
// Honours an inbound X-Request-Id so a self-hosted bot can correlate its own
// logs with the backend's.
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" || len(id) > 64 {
var b [8]byte
if _, err := rand.Read(b[:]); err == nil {
id = hex.EncodeToString(b[:])
}
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id)))
})
}
// statusRecorder captures the status code, which http.ResponseWriter does not
// expose after the fact.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.status == 0 {
s.status = http.StatusOK
}
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
// WithLogging records one line per request.
//
// Deliberately does NOT log the Authorization header, the query string or the
// body. Logs get shipped, grepped and retained far longer than anyone intends,
// and the query string is where callsigns and grid squares live.
func WithLogging(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if rec.status == 0 {
rec.status = http.StatusOK
}
attrs := []any{
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFrom(r.Context()),
}
// Key id rather than the key, and only once authentication has
// resolved one.
if p := PrincipalFrom(r.Context()); p != nil {
attrs = append(attrs, "key_id", p.KeyID, "owner_id", p.OwnerID)
}
level := slog.LevelInfo
if rec.status >= 500 {
level = slog.LevelError
}
logger.Log(r.Context(), level, "request", attrs...)
})
}
}
// WithRecovery turns a panic in a handler into a 500 rather than a dead
// process. Go's default is to kill the whole server on an unrecovered panic in
// a handler goroutine.
func WithRecovery(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
// A closed connection surfaces as a panic with this value and
// is not a bug; re-panic so net/http handles it as it expects.
if rec == http.ErrAbortHandler {
panic(rec)
}
logger.Error("panic in handler",
"err", rec,
"path", r.URL.Path,
"request_id", RequestIDFrom(r.Context()),
"stack", string(debug.Stack()))
WriteError(w, http.StatusInternalServerError, CodeInternal,
"Something went wrong. The incident has been logged.")
}()
next.ServeHTTP(w, r)
})
}
}
// Chain applies middleware so that the first argument is the outermost, which
// reads in the order requests actually traverse them.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
+154
View File
@@ -0,0 +1,154 @@
package api
import (
"log/slog"
"net/http"
"strings"
)
// Router builds the HTTP surface. Kept out of main.go so it can be exercised in
// tests with a fake KeyStore, which is where the auth wiring actually gets
// verified.
type Router struct {
Auth *Authenticator
Logger *slog.Logger
Version string
// Ready reports whether dependencies are healthy. Nil means always ready.
Ready func() error
}
func (rt *Router) Handler() http.Handler {
mux := http.NewServeMux()
// Unauthenticated. Liveness must not touch the database: a health check
// that fails when Postgres blips will get the process killed by whatever
// supervises it, which turns a database wobble into an outage.
mux.Handle("GET /healthz", rt.healthz())
// Readiness DOES check dependencies. That is the difference.
mux.Handle("GET /readyz", rt.readyz())
// Everything below needs a key.
authed := func(scope string, h http.Handler) http.Handler {
return rt.Auth.Authenticate(RequireScope(scope, h))
}
mux.Handle("GET /v1/whoami", rt.Auth.Authenticate(Whoami()))
mux.Handle("GET /v1/reference/ping", authed(ScopeReference, rt.stub("reference")))
// Deliberately NO catch-all mux.Handle("/", ...) here.
//
// A "/" pattern matches every path, so it would also swallow method
// mismatches: POST /healthz would match "/" and return 404 instead of the
// 405 that Go's ServeMux produces on its own. jsonifyErrors below rewrites
// ServeMux's plain-text 404 and 405 bodies into the standard envelope,
// which keeps both correct status codes and uniform JSON errors.
return Chain(mux,
WithRequestID,
WithRecovery(rt.Logger),
WithLogging(rt.Logger),
jsonifyErrors,
)
}
func (rt *Router) healthz() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"version": rt.Version,
})
})
}
func (rt *Router) readyz() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if rt.Ready != nil {
if err := rt.Ready(); err != nil {
rt.Logger.Warn("readiness check failed", "err", err)
WriteError(w, http.StatusServiceUnavailable, "not_ready", "Dependencies are unavailable.")
return
}
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ready"})
})
}
// jsonifyErrors converts net/http's built-in plain-text 404 and 405 responses
// into the same JSON envelope every other error uses, so a client never has to
// branch on Content-Type to read an error.
func jsonifyErrors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rw := &errorRewriter{ResponseWriter: w}
next.ServeHTTP(rw, r)
rw.finish()
})
}
type errorRewriter struct {
http.ResponseWriter
intercepted bool
wroteHeader bool
status int
}
func (e *errorRewriter) WriteHeader(code int) {
if e.wroteHeader {
return
}
e.wroteHeader = true
e.status = code
// Only touch responses net/http generated itself.
//
// Checking for an EMPTY Content-Type does not work: http.NotFound sets
// "text/plain; charset=utf-8" before calling WriteHeader, so by the time we
// see it the header is already populated. Test for "not already JSON"
// instead - every handler in this package writes JSON, so anything else at
// a 404 or 405 came from net/http.
if code == http.StatusNotFound || code == http.StatusMethodNotAllowed {
if !strings.HasPrefix(e.Header().Get("Content-Type"), "application/json") {
e.intercepted = true
e.Header().Set("Content-Type", "application/json; charset=utf-8")
}
}
e.ResponseWriter.WriteHeader(code)
}
func (e *errorRewriter) Write(b []byte) (int, error) {
if !e.wroteHeader {
e.WriteHeader(http.StatusOK)
}
// Swallow the plain-text body; finish writes the JSON one.
if e.intercepted {
return len(b), nil
}
return e.ResponseWriter.Write(b)
}
func (e *errorRewriter) finish() {
if !e.intercepted {
return
}
code, msg := "not_found", "No such endpoint."
if e.status == http.StatusMethodNotAllowed {
code, msg = "method_not_allowed", "That method is not allowed on this endpoint."
}
_ = writeErrorBody(e.ResponseWriter, code, msg)
}
func (rt *Router) stub(name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{"endpoint": name, "status": "not implemented yet"})
})
}
+214
View File
@@ -0,0 +1,214 @@
package api
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"git.dcrubro.com/dcrubro/hammy-backend/internal/apikey"
"git.dcrubro.com/dcrubro/hammy-backend/internal/ratelimit"
)
func testRouter(t *testing.T, ready func() error) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
rt := &Router{
Auth: &Authenticator{
Keys: &fakeStore{rec: activeRecord()},
Limiter: ratelimit.NewMemory(),
Logger: logger,
},
Logger: logger,
Version: "test",
Ready: ready,
}
return rt.Handler()
}
func TestHealthzNeedsNoKey(t *testing.T) {
h := testRouter(t, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if w.Code != http.StatusOK {
t.Fatalf("status %d, want 200", w.Code)
}
if id := w.Header().Get("X-Request-Id"); id == "" {
t.Error("no X-Request-Id on the response")
}
}
// Liveness must not depend on Postgres. If it did, a database blip would get
// the process killed by its supervisor and turn a wobble into an outage.
func TestHealthzIgnoresDependencies(t *testing.T) {
h := testRouter(t, func() error { return errors.New("postgres down") })
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if w.Code != http.StatusOK {
t.Errorf("healthz status %d with a failing dependency, want 200", w.Code)
}
}
func TestReadyzReflectsDependencies(t *testing.T) {
h := testRouter(t, func() error { return errors.New("postgres down") })
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if w.Code != http.StatusServiceUnavailable {
t.Errorf("readyz status %d, want 503", w.Code)
}
ok := testRouter(t, func() error { return nil })
w = httptest.NewRecorder()
ok.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if w.Code != http.StatusOK {
t.Errorf("healthy readyz status %d, want 200", w.Code)
}
}
func TestV1RequiresKey(t *testing.T) {
h := testRouter(t, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/whoami", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("status %d, want 401", w.Code)
}
}
func TestWhoamiEndToEnd(t *testing.T) {
h := testRouter(t, nil)
g, err := apikey.Generate(apikey.Live)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, "/v1/whoami", nil)
r.Header.Set("Authorization", "Bearer "+g.Plaintext)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var got struct {
KeyID int64 `json:"key_id"`
OwnerID int64 `json:"owner_id"`
Scopes []string `json:"scopes"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.KeyID != 42 || got.OwnerID != 7 {
t.Errorf("principal not propagated: %+v", got)
}
}
func TestScopeEnforcedOnRoute(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
rec := activeRecord()
rec.Scopes = []string{ScopeLogbook} // deliberately not reference
rt := &Router{
Auth: &Authenticator{
Keys: &fakeStore{rec: rec},
Limiter: ratelimit.NewMemory(),
Logger: logger,
},
Logger: logger,
}
g, _ := apikey.Generate(apikey.Live)
r := httptest.NewRequest(http.MethodGet, "/v1/reference/ping", nil)
r.Header.Set("Authorization", "Bearer "+g.Plaintext)
w := httptest.NewRecorder()
rt.Handler().ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("status %d, want 403", w.Code)
}
}
func TestUnknownPathIsJSON404(t *testing.T) {
h := testRouter(t, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/nope", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("status %d, want 404", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct[:16] != "application/json" {
t.Errorf("Content-Type %q, want JSON so clients can parse errors uniformly", ct)
}
}
// A wrong method on a real path must 405, not 404.
func TestMethodMismatch(t *testing.T) {
h := testRouter(t, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/healthz", nil))
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status %d, want 405", w.Code)
}
}
// A panicking handler must produce a 500, not kill the process.
func TestRecoveryMiddleware(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("kaboom")
})
h := Chain(boom, WithRequestID, WithRecovery(logger))
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
if w.Code != http.StatusInternalServerError {
t.Fatalf("status %d, want 500", w.Code)
}
if got := bodyCode(t, w); got != CodeInternal {
t.Errorf("code %q, want %q", got, CodeInternal)
}
}
func TestRequestIDHonoursInbound(t *testing.T) {
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := RequestIDFrom(r.Context()); got != "abc123" {
t.Errorf("request id %q, want abc123", got)
}
}), WithRequestID)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-Request-Id", "abc123")
h.ServeHTTP(httptest.NewRecorder(), r)
}
+5 -4
View File
@@ -10,6 +10,7 @@ import (
) )
// PgKeyStore adapts the sqlc-generated queries to the KeyStore interface. // PgKeyStore adapts the sqlc-generated queries to the KeyStore interface.
//
// The indirection earns its keep twice: the middleware is testable without a // The indirection earns its keep twice: the middleware is testable without a
// database, and adding an unrelated column to api.keys does not ripple into the // database, and adding an unrelated column to api.keys does not ripple into the
// auth package. // auth package.
@@ -38,10 +39,10 @@ func (s PgKeyStore) KeyByHash(ctx context.Context, hash []byte) (KeyRecord, erro
OwnerStatus: row.OwnerStatus, OwnerStatus: row.OwnerStatus,
} }
// sqlc with emit_pointers_for_null_types gives *time.Time for a nullable // pgx maps a nullable timestamptz to pgtype.Timestamptz, which carries its
// timestamptz. If your generated code uses pgtype.Timestamptz instead, // own validity flag rather than being nil. db.Time does the conversion so
// convert here rather than in the middleware. // pgtype never leaks past this adapter.
rec.ExpiresAt = row.ExpiresAt rec.ExpiresAt = db.Time(row.ExpiresAt)
return rec, nil return rec, nil
} }
+184
View File
@@ -0,0 +1,184 @@
// Package config loads runtime settings from the environment.
//
// Environment variables rather than a config file, for two reasons: the DSN
// differs per machine and you are syncing the repo across two, and a file with
// credentials in it is one `git add .` away from being public. The bot uses
// config.json because its token is the only secret; the backend holds database
// credentials and key material.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
// Addr is the listen address. Bind to localhost in production and put a
// reverse proxy in front for TLS.
Addr string
// DSN for Postgres. Required.
DSN string
// MaxConns caps the pgx pool. Postgres forks a backend per connection, so
// this is a real resource, not a client-side nicety.
MaxConns int32
// RedisAddr enables the shared rate limiter. Empty falls back to an
// in-process limiter, which is correct for a single instance and wrong for
// several - each would keep its own counters.
RedisAddr string
RedisDB int
// EndUserHeader carries the caller's identifier for whoever triggered the
// request, for per-user rate limiting. Empty disables it.
EndUserHeader string
LogLevel slog.Level
LogJSON bool
ReadHeaderTimeout time.Duration
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
c := Config{
Addr: env("HAMMY_ADDR", "127.0.0.1:8080"),
DSN: os.Getenv("HAMMY_DSN"),
RedisAddr: os.Getenv("HAMMY_REDIS_ADDR"),
EndUserHeader: env("HAMMY_ENDUSER_HEADER", "X-Hammy-User"),
ReadHeaderTimeout: 10 * time.Second,
ShutdownTimeout: 20 * time.Second,
LogJSON: boolEnv("HAMMY_LOG_JSON", false),
}
if c.DSN == "" {
return Config{}, errors.New("config: HAMMY_DSN is not set")
}
n, err := intEnv("HAMMY_MAX_CONNS", 10)
if err != nil {
return Config{}, err
}
c.MaxConns = int32(n)
if c.RedisDB, err = intEnv("HAMMY_REDIS_DB", 0); err != nil {
return Config{}, err
}
if c.LogLevel, err = levelEnv("HAMMY_LOG_LEVEL", slog.LevelInfo); err != nil {
return Config{}, err
}
if c.ReadHeaderTimeout, err = durEnv("HAMMY_READ_HEADER_TIMEOUT", c.ReadHeaderTimeout); err != nil {
return Config{}, err
}
if c.ShutdownTimeout, err = durEnv("HAMMY_SHUTDOWN_TIMEOUT", c.ShutdownTimeout); err != nil {
return Config{}, err
}
return c, nil
}
// Redacted is safe to log at startup. The DSN carries a password.
func (c Config) Redacted() string {
redis := c.RedisAddr
if redis == "" {
redis = "(in-process limiter)"
}
return fmt.Sprintf("addr=%s db=%s redis=%s max_conns=%d level=%s",
c.Addr, redactDSN(c.DSN), redis, c.MaxConns, c.LogLevel)
}
// redactDSN keeps the shape of the connection string and drops the password.
func redactDSN(dsn string) string {
at := strings.LastIndex(dsn, "@")
if at < 0 {
return "(set)"
}
scheme := strings.Index(dsn, "://")
if scheme < 0 {
return "(set)"
}
creds := dsn[scheme+3 : at]
user := creds
if colon := strings.Index(creds, ":"); colon >= 0 {
user = creds[:colon]
}
return dsn[:scheme+3] + user + ":***" + dsn[at:]
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func intEnv(key string, def int) (int, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return n, nil
}
func boolEnv(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func durEnv(key string, def time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return d, nil
}
func levelEnv(key string, def slog.Level) (slog.Level, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
var l slog.Level
if err := l.UnmarshalText([]byte(v)); err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return l, nil
}
+103
View File
@@ -0,0 +1,103 @@
package config
import (
"log/slog"
"strings"
"testing"
"time"
)
func TestLoadRequiresDSN(t *testing.T) {
t.Setenv("HAMMY_DSN", "")
if _, err := Load(); err == nil {
t.Fatal("expected an error when HAMMY_DSN is unset")
}
}
func TestLoadDefaults(t *testing.T) {
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Addr != "127.0.0.1:8080" {
t.Errorf("Addr %q, want localhost by default - binding 0.0.0.0 should be deliberate", c.Addr)
}
if c.MaxConns != 10 {
t.Errorf("MaxConns %d, want 10", c.MaxConns)
}
if c.LogLevel != slog.LevelInfo {
t.Errorf("LogLevel %v, want info", c.LogLevel)
}
if c.RedisAddr != "" {
t.Errorf("RedisAddr %q, want empty", c.RedisAddr)
}
}
func TestLoadOverrides(t *testing.T) {
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
t.Setenv("HAMMY_ADDR", ":9000")
t.Setenv("HAMMY_MAX_CONNS", "25")
t.Setenv("HAMMY_LOG_LEVEL", "debug")
t.Setenv("HAMMY_SHUTDOWN_TIMEOUT", "45s")
t.Setenv("HAMMY_REDIS_ADDR", "localhost:6379")
c, err := Load()
if err != nil {
t.Fatal(err)
}
if c.Addr != ":9000" || c.MaxConns != 25 || c.LogLevel != slog.LevelDebug {
t.Errorf("overrides not applied: %+v", c)
}
if c.ShutdownTimeout != 45*time.Second {
t.Errorf("ShutdownTimeout %v, want 45s", c.ShutdownTimeout)
}
}
func TestLoadRejectsBadValues(t *testing.T) {
t.Setenv("HAMMY_DSN", "postgres://u:p@localhost/hammy")
for _, c := range []struct{ key, val string }{
{"HAMMY_MAX_CONNS", "lots"},
{"HAMMY_LOG_LEVEL", "verbose"},
{"HAMMY_SHUTDOWN_TIMEOUT", "soon"},
} {
t.Run(c.key, func(t *testing.T) {
t.Setenv(c.key, c.val)
if _, err := Load(); err == nil {
t.Errorf("%s=%q accepted", c.key, c.val)
}
})
}
}
// A DSN in a startup log is a password in a log.
func TestRedactedHidesPassword(t *testing.T) {
cases := []string{
"postgres://hammy_app:[email protected]:5432/hammy",
"postgresql://hammy_app:p%40ss@localhost/hammy?sslmode=verify-full",
}
for _, dsn := range cases {
c := Config{DSN: dsn, Addr: ":8080", MaxConns: 10}
out := c.Redacted()
if strings.Contains(out, "hunter2") || strings.Contains(out, "p%40ss") {
t.Errorf("password leaked: %s", out)
}
if !strings.Contains(out, "hammy_app") {
t.Errorf("username should survive for diagnosis: %s", out)
}
}
// A DSN with no credentials must not panic or produce nonsense.
c := Config{DSN: "postgres:///hammy?host=/var/run/postgresql"}
if out := c.Redacted(); strings.Contains(out, "***") && !strings.Contains(out, "(set)") {
t.Logf("socket DSN rendered as: %s", out)
}
}
+58
View File
@@ -0,0 +1,58 @@
package db
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// Conversions between pgtype and plain Go types.
// sqlc with sql_package: pgx/v5 maps a nullable timestamptz to
// pgtype.Timestamptz regardless of emit_pointers_for_null_types, and the
// db_type override for it is fiddly to get right across sqlc versions. Doing
// the conversion here is two lines, always works, and keeps pgtype out of
// internal/api and cmd/ entirely.
// This file is hand-written and is NOT regenerated by sqlc. It lives in the db
// package so the pgtype import stays in one place.
// Time converts a nullable timestamptz to *time.Time. NULL becomes nil.
func Time(t pgtype.Timestamptz) *time.Time {
if !t.Valid {
return nil
}
v := t.Time
return &v
}
// Timestamptz converts *time.Time back for a query parameter. nil becomes NULL.
func Timestamptz(t *time.Time) pgtype.Timestamptz {
if t == nil {
return pgtype.Timestamptz{}
}
return pgtype.Timestamptz{Time: *t, Valid: true}
}
// Str converts a nullable text column to *string.
func Str(t pgtype.Text) *string {
if !t.Valid {
return nil
}
v := t.String
return &v
}
// Text converts a string for a nullable text parameter. An empty string becomes
// NULL: an absent label or callsign is absence, not a value, and the
// core.callsign domain would reject "" anyway.
func Text(s string) pgtype.Text {
if s == "" {
return pgtype.Text{}
}
return pgtype.Text{String: s, Valid: true}
}
+216 -7
View File
@@ -11,15 +11,102 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const getKeyByHash = `-- name: GetKeyByHash :one const createKey = `-- name: CreateKey :one
SELECT k.id, k.owner_id, k.scopes, k.quota_tier, k.status, k.expires_at, INSERT INTO api.keys (owner_id, key_hash, key_prefix, label, scopes, quota_tier, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, key_prefix, scopes, quota_tier, status, created_at, expires_at
`
type CreateKeyParams struct {
OwnerID int64
KeyHash []byte
KeyPrefix string
Label *string
Scopes []string
QuotaTier string
ExpiresAt pgtype.Timestamptz
}
type CreateKeyRow struct {
ID int64
KeyPrefix string
Scopes []string
QuotaTier string
Status string
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
}
func (q *Queries) CreateKey(ctx context.Context, arg CreateKeyParams) (CreateKeyRow, error) {
row := q.db.QueryRow(ctx, createKey,
arg.OwnerID,
arg.KeyHash,
arg.KeyPrefix,
arg.Label,
arg.Scopes,
arg.QuotaTier,
arg.ExpiresAt,
)
var i CreateKeyRow
err := row.Scan(
&i.ID,
&i.KeyPrefix,
&i.Scopes,
&i.QuotaTier,
&i.Status,
&i.CreatedAt,
&i.ExpiresAt,
)
return i, err
}
const createOwner = `-- name: CreateOwner :one
INSERT INTO api.owners (email, callsign)
VALUES ($1, $2)
RETURNING id, email, callsign, status, created_at
`
type CreateOwnerParams struct {
Email string
Callsign interface{}
}
type CreateOwnerRow struct {
ID int64
Email string
Callsign interface{}
Status string
CreatedAt pgtype.Timestamptz
}
func (q *Queries) CreateOwner(ctx context.Context, arg CreateOwnerParams) (CreateOwnerRow, error) {
row := q.db.QueryRow(ctx, createOwner, arg.Email, arg.Callsign)
var i CreateOwnerRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Callsign,
&i.Status,
&i.CreatedAt,
)
return i, err
}
const keyByHash = `-- name: KeyByHash :one
SELECT
k.id,
k.owner_id,
k.scopes,
k.quota_tier,
k.status,
k.expires_at,
o.status AS owner_status o.status AS owner_status
FROM api.keys k FROM api.keys k
JOIN api.owners o ON o.id = k.owner_id JOIN api.owners o ON o.id = k.owner_id
WHERE k.key_hash = $1 AND k.status = 'active' WHERE k.key_hash = $1
` `
type GetKeyByHashRow struct { type KeyByHashRow struct {
ID int64 ID int64
OwnerID int64 OwnerID int64
Scopes []string Scopes []string
@@ -29,9 +116,15 @@ type GetKeyByHashRow struct {
OwnerStatus string OwnerStatus string
} }
func (q *Queries) GetKeyByHash(ctx context.Context, keyHash []byte) (GetKeyByHashRow, error) { // The authentication hot path. Joins owners so one round trip settles both the
row := q.db.QueryRow(ctx, getKeyByHash, keyHash) // key's status and the account's.
var i GetKeyByHashRow //
// No filter on status here: the middleware needs to distinguish "revoked" from
// "expired" from "unknown" to give a useful error, and it cannot do that if the
// query has already hidden the row.
func (q *Queries) KeyByHash(ctx context.Context, keyHash []byte) (KeyByHashRow, error) {
row := q.db.QueryRow(ctx, keyByHash, keyHash)
var i KeyByHashRow
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.OwnerID, &i.OwnerID,
@@ -43,3 +136,119 @@ func (q *Queries) GetKeyByHash(ctx context.Context, keyHash []byte) (GetKeyByHas
) )
return i, err return i, err
} }
const listKeysForOwner = `-- name: ListKeysForOwner :many
SELECT id, key_prefix, label, scopes, quota_tier, status,
created_at, expires_at, last_used_at, revoked_at
FROM api.keys
WHERE owner_id = $1
ORDER BY created_at DESC
`
type ListKeysForOwnerRow struct {
ID int64
KeyPrefix string
Label *string
Scopes []string
QuotaTier string
Status string
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
LastUsedAt pgtype.Timestamptz
RevokedAt pgtype.Timestamptz
}
func (q *Queries) ListKeysForOwner(ctx context.Context, ownerID int64) ([]ListKeysForOwnerRow, error) {
rows, err := q.db.Query(ctx, listKeysForOwner, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListKeysForOwnerRow{}
for rows.Next() {
var i ListKeysForOwnerRow
if err := rows.Scan(
&i.ID,
&i.KeyPrefix,
&i.Label,
&i.Scopes,
&i.QuotaTier,
&i.Status,
&i.CreatedAt,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ownerByEmail = `-- name: OwnerByEmail :one
SELECT id, email, email_verified_at, callsign, callsign_verified_at, status, created_at
FROM api.owners
WHERE email = $1
`
type OwnerByEmailRow struct {
ID int64
Email string
EmailVerifiedAt pgtype.Timestamptz
Callsign interface{}
CallsignVerifiedAt pgtype.Timestamptz
Status string
CreatedAt pgtype.Timestamptz
}
func (q *Queries) OwnerByEmail(ctx context.Context, email string) (OwnerByEmailRow, error) {
row := q.db.QueryRow(ctx, ownerByEmail, email)
var i OwnerByEmailRow
err := row.Scan(
&i.ID,
&i.Email,
&i.EmailVerifiedAt,
&i.Callsign,
&i.CallsignVerifiedAt,
&i.Status,
&i.CreatedAt,
)
return i, err
}
const revokeKey = `-- name: RevokeKey :exec
UPDATE api.keys
SET status = 'revoked', revoked_at = now(), revoked_reason = $2
WHERE id = $1
AND status <> 'revoked'
`
type RevokeKeyParams struct {
ID int64
RevokedReason *string
}
// The keys_revoked_consistent CHECK requires status and revoked_at to move
// together, so both are set here.
func (q *Queries) RevokeKey(ctx context.Context, arg RevokeKeyParams) error {
_, err := q.db.Exec(ctx, revokeKey, arg.ID, arg.RevokedReason)
return err
}
const touchKeyLastUsed = `-- name: TouchKeyLastUsed :exec
UPDATE api.keys
SET last_used_at = now()
WHERE id = $1
AND (last_used_at IS NULL OR last_used_at < now() - interval '5 minutes')
`
// Debounced by the caller: writing on every request would be one UPDATE per
// request for a field nobody reads in real time.
func (q *Queries) TouchKeyLastUsed(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, touchKeyLastUsed, id)
return err
}
+3 -3
View File
@@ -28,7 +28,7 @@ type ApiKey struct {
// Registrants for API keys. Holds PII from an international user base, so the deletion path in 005_privacy.sql applies here too, not just to Discord users. // Registrants for API keys. Holds PII from an international user base, so the deletion path in 005_privacy.sql applies here too, not just to Discord users.
type ApiOwner struct { type ApiOwner struct {
ID int64 ID int64
Email interface{} Email string
EmailVerifiedAt pgtype.Timestamptz EmailVerifiedAt pgtype.Timestamptz
Callsign interface{} Callsign interface{}
CallsignVerifiedAt pgtype.Timestamptz CallsignVerifiedAt pgtype.Timestamptz
@@ -75,7 +75,7 @@ type CoreVerification struct {
ID int64 ID int64
UserID int64 UserID int64
Method string Method string
Callsign interface{} Callsign string
Status string Status string
ChallengeHash []byte ChallengeHash []byte
Detail []byte Detail []byte
@@ -103,7 +103,7 @@ type LogbookQso struct {
ID int64 ID int64
UserID int64 UserID int64
ImportID *int64 ImportID *int64
Callsign interface{} Callsign string
QsoAt pgtype.Timestamptz QsoAt pgtype.Timestamptz
// Truncated copy of qso_at used only for duplicate detection. Do not query it directly; use qso_at. // Truncated copy of qso_at used only for duplicate detection. Do not query it directly; use qso_at.
QsoMinute pgtype.Timestamp QsoMinute pgtype.Timestamp
+54 -3
View File
@@ -1,6 +1,57 @@
-- name: GetKeyByHash :one -- name: KeyByHash :one
SELECT k.id, k.owner_id, k.scopes, k.quota_tier, k.status, k.expires_at, -- The authentication hot path. Joins owners so one round trip settles both the
-- key's status and the account's.
--
-- No filter on status here: the middleware needs to distinguish "revoked" from
-- "expired" from "unknown" to give a useful error, and it cannot do that if the
-- query has already hidden the row.
SELECT
k.id,
k.owner_id,
k.scopes,
k.quota_tier,
k.status,
k.expires_at,
o.status AS owner_status o.status AS owner_status
FROM api.keys k FROM api.keys k
JOIN api.owners o ON o.id = k.owner_id JOIN api.owners o ON o.id = k.owner_id
WHERE k.key_hash = $1 AND k.status = 'active'; WHERE k.key_hash = $1;
-- name: TouchKeyLastUsed :exec
-- Debounced by the caller: writing on every request would be one UPDATE per
-- request for a field nobody reads in real time.
UPDATE api.keys
SET last_used_at = now()
WHERE id = $1
AND (last_used_at IS NULL OR last_used_at < now() - interval '5 minutes');
-- name: CreateOwner :one
INSERT INTO api.owners (email, callsign)
VALUES ($1, $2)
RETURNING id, email, callsign, status, created_at;
-- name: OwnerByEmail :one
SELECT id, email, email_verified_at, callsign, callsign_verified_at, status, created_at
FROM api.owners
WHERE email = $1;
-- name: CreateKey :one
INSERT INTO api.keys (owner_id, key_hash, key_prefix, label, scopes, quota_tier, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, key_prefix, scopes, quota_tier, status, created_at, expires_at;
-- name: ListKeysForOwner :many
SELECT id, key_prefix, label, scopes, quota_tier, status,
created_at, expires_at, last_used_at, revoked_at
FROM api.keys
WHERE owner_id = $1
ORDER BY created_at DESC;
-- name: RevokeKey :exec
-- The keys_revoked_consistent CHECK requires status and revoked_at to move
-- together, so both are set here.
UPDATE api.keys
SET status = 'revoked', revoked_at = now(), revoked_reason = $2
WHERE id = $1
AND status <> 'revoked';
+18 -1
View File
@@ -1,16 +1,33 @@
# sqlc.yaml
version: "2" version: "2"
sql: sql:
- engine: "postgresql" - engine: "postgresql"
queries: "queries" queries: "queries"
# Only the in-database DDL. scripts/bootstrap.sql is deliberately absent:
# sqlc parses SQL to learn the schema and cannot handle CREATE ROLE,
# ALTER DEFAULT PRIVILEGES or DO $$ blocks.
schema: schema:
- "migrations/001_types.sql" - "migrations/001_types.sql"
- "migrations/002_core.sql" - "migrations/002_core.sql"
- "migrations/003_api.sql" - "migrations/003_api.sql"
- "migrations/004_logbook.sql" - "migrations/004_logbook.sql"
gen: gen:
go: go:
package: "db" package: "db"
out: "internal/db" out: "internal/db"
sql_package: "pgx/v5" sql_package: "pgx/v5"
# Nullable columns become *T rather than sql.NullT, which is what
# internal/api/store.go expects for expires_at.
emit_pointers_for_null_types: true emit_pointers_for_null_types: true
emit_empty_slices: true
overrides:
# The domains are text underneath; sqlc should not invent a type.
- db_type: "core.callsign"
go_type: "string"
- db_type: "core.email"
go_type: "string"
- db_type: "core.gridsquare"
go_type: "string"