commit 6aade62bf598042cf212170d8b1af094102654ce Author: Jason Ross Date: Sat Jun 20 19:00:11 2026 -0500 mvp shell diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1372b84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Go +/backend/bin/ +*.test +*.out + +# Node / Vite +node_modules/ +/frontend/dist/ +.vite/ + +# Env / secrets +*.env +.env +.env.* +!.env.example +*-credentials.json +*.pem +*.key +!**/sample.key +secrets/ +*.vault + +# Pulumi +Pulumi.*.yaml.bak +.pulumi/ +/infra/pulumi/pulumi +/infra/pulumi/pulumi.exe + +# Ansible +*.retry +infra/ansible/inventory/hosts.generated.ini +infra/ansible/vault_pass.txt + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..85f6ccb --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +# idea-collect — developer tasks +# Override COMPOSE with `podman compose` or `podman-compose` if you prefer Podman locally. +COMPOSE ?= docker compose +DB_URL ?= postgres://idea:idea@localhost:5432/idea?sslmode=disable + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## Show this help + @grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | \ + awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' + +## ---- Database ---- +.PHONY: db-up db-down db-logs +db-up: ## Start Postgres 18 (dev) + $(COMPOSE) -f compose.dev.yml up -d db +db-down: ## Stop dev Postgres (keeps volume) + $(COMPOSE) -f compose.dev.yml down +db-logs: ## Tail Postgres logs + $(COMPOSE) -f compose.dev.yml logs -f db + +## ---- Backend ---- +.PHONY: backend-run backend-build backend-test admin +backend-run: ## Run the API server (applies migrations) + cd backend && DATABASE_URL="$(DB_URL)" go run ./cmd/server +backend-build: ## Build server + admin binaries into backend/bin + cd backend && go build -o bin/server ./cmd/server && go build -o bin/admin ./cmd/admin +backend-test: ## Run Go tests + cd backend && go test ./... +admin: ## Run the admin CLI: make admin ARGS="user create --project demo --name 'Jane'" + cd backend && DATABASE_URL="$(DB_URL)" go run ./cmd/admin $(ARGS) + +## ---- Frontend ---- +.PHONY: frontend-dev frontend-build frontend-install +frontend-install: ## Install frontend deps + cd frontend && npm install +frontend-dev: ## Run Vite dev server (proxies /api -> :8080) + cd frontend && npm run dev +frontend-build: ## Production build into frontend/dist + cd frontend && npm run build + +## ---- End-to-end ---- +.PHONY: e2e +e2e: db-up ## Start DB then run backend (use frontend-dev in another shell) + @echo "Postgres up. In another terminal: make frontend-dev" + $(MAKE) backend-run + +## ---- Containers ---- +# Use podman locally to mirror production; override with ENGINE=docker. +ENGINE ?= podman +.PHONY: images +images: ## Build the backend + custom Caddy images + $(ENGINE) build -t localhost/idea-collect-backend:latest -f backend/Containerfile backend + $(ENGINE) build -t localhost/idea-collect-caddy:latest deploy/caddy diff --git a/README.md b/README.md new file mode 100644 index 0000000..8128739 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# idea-collect + +A lightweight idea/feedback collection tool for non-technical audiences. An invited person +enters an invite/auth code, fills in a simple form, and their submission lands on a GitHub +Projects v2 Kanban board as an Issue with their name in the body. + +## Stack + +| Layer | Choice | +|--------------|--------| +| Frontend | Vite + Svelte + TypeScript (static SPA) | +| Backend | Go (`net/http`, `pgx`) | +| Database | PostgreSQL 18 | +| GitHub | GitHub App → Issues + Projects v2 (GraphQL) | +| Runtime | Rootless Podman quadlets (Caddy → backend → Postgres) | +| TLS / WAF | Caddy (Coraza WAF + Google Cloud DNS ACME DNS-01) | +| Firewall | Hetzner Cloud firewall + host nftables + fail2ban | +| IaC | Pulumi (Go) — Hetzner Cloud + Google Cloud DNS, state in Cloudflare R2 | +| Config mgmt | Ansible | + +## Layout + +``` +frontend/ Vite + Svelte + TS SPA +backend/ Go API server (cmd/server) + admin CLI (cmd/admin) +infra/ + pulumi/ Pulumi Go program (hcloud + gcp dns) + ansible/ roles + playbooks (host hardening + deploy) +deploy/ + caddy/ custom Caddy (xcaddy) image + Caddyfile + CRS + quadlets/ Podman *.container / *.network / *.volume units + fail2ban/ jails + filters + nftables/ ruleset +``` + +## Quick start (local dev) + +Prerequisites: Go 1.26+, Node 20+, Podman or Docker. + +```bash +# 1. Start Postgres 18 +make db-up + +# 2. Run the backend (applies migrations on startup) +make backend-run # listens on :8080 + +# 3. Create a project + a user (prints an auth code) +make admin ARGS="project create --slug demo --name 'Demo App' \ + --owner my-org --repo demo-feedback --project-number 1" +make admin ARGS="user create --project demo --name 'Jane Doe' --email jane@example.com" + +# 4. Run the frontend (proxies /api -> :8080) +make frontend-dev # http://localhost:5173 +``` + +See `make help` for all targets. Configuration is via environment variables — copy +`backend/.env.example` to `backend/.env`. + +## Deployment + +1. **Provision** infrastructure with Pulumi (`infra/pulumi`) — creates the Hetzner server, + firewall, Google Cloud DNS zone/records and a DNS service account. State lives in + Cloudflare R2. +2. **Configure** the host with Ansible (`infra/ansible`) — hardening, nftables, rootless + Podman, fail2ban, secrets, then deploys the quadlet units and runs migrations. + +Full runbook: [`infra/README.md`](infra/README.md). diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..4b23a42 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,29 @@ +# Copy to backend/.env for local dev (Makefile passes DATABASE_URL explicitly). + +# --- Database --- +DATABASE_URL=postgres://idea:idea@localhost:5432/idea?sslmode=disable + +# --- HTTP server --- +LISTEN_ADDR=:8080 +# Comma-separated allowed CORS origins (the SPA origin). Empty = same-origin only. +ALLOWED_ORIGINS=http://localhost:5173 +# Set true when served over HTTPS so the session cookie gets the Secure flag. +COOKIE_SECURE=false +# Session lifetime. +SESSION_TTL=2h +# 32+ byte random secret used to sign session cookies. Generate with: +# openssl rand -base64 48 +SESSION_SECRET=dev-only-insecure-change-me-0000000000000000 + +# --- Rate limiting (per client IP, on /api/auth) --- +AUTH_RATE_PER_MINUTE=10 +AUTH_RATE_BURST=5 + +# --- GitHub App --- +GITHUB_APP_ID=123456 +GITHUB_APP_INSTALLATION_ID=12345678 +# Path to the App private key PEM (mounted as a secret in production). +GITHUB_APP_PRIVATE_KEY_FILE=./github-app.private-key.pem +# Optional: GitHub Enterprise base URLs. Leave blank for github.com. +GITHUB_API_BASE= +GITHUB_GRAPHQL_URL= diff --git a/backend/Containerfile b/backend/Containerfile new file mode 100644 index 0000000..03673a2 --- /dev/null +++ b/backend/Containerfile @@ -0,0 +1,18 @@ +# Backend image: static Go binary on distroless (no shell, runs as nonroot). +# Build context is the backend/ directory: +# podman build -t localhost/idea-collect-backend:latest -f backend/Containerfile backend +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server \ + && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/admin ./cmd/admin + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/server /usr/local/bin/server +COPY --from=build /out/admin /usr/local/bin/admin +USER nonroot:nonroot +EXPOSE 8080 +# Health is checked by Caddy's reverse_proxy (health_uri /healthz) since distroless has no shell. +ENTRYPOINT ["/usr/local/bin/server"] diff --git a/backend/cmd/admin/main.go b/backend/cmd/admin/main.go new file mode 100644 index 0000000..6b4d1fb --- /dev/null +++ b/backend/cmd/admin/main.go @@ -0,0 +1,277 @@ +// Command admin is the operator CLI for provisioning projects, users (invite codes), +// and retrying failed GitHub submissions. Run it on the host over SSH. +// +// Usage: +// +// admin project create --slug demo --name "Demo" --owner org --repo demo-fb --project-number 1 +// admin project list +// admin user create --project demo --name "Jane Doe" --email jane@example.com +// admin user revoke --code ABCD-1234-WXYZ +// admin user list --project demo +// admin submission retry --limit 50 +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "text/tabwriter" + + "github.com/jasonross/idea-collect/backend/internal/authcode" + "github.com/jasonross/idea-collect/backend/internal/config" + "github.com/jasonross/idea-collect/backend/internal/github" + "github.com/jasonross/idea-collect/backend/internal/store" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) < 2 { + usage() + return errors.New("expected: admin ") + } + group, sub, rest := args[0], args[1], args[2:] + + cfg, err := config.Load() + if err != nil { + return err + } + ctx := context.Background() + st, err := store.New(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer st.Close() + if err := st.Migrate(ctx); err != nil { + return err + } + + a := &app{cfg: cfg, st: st} + switch group + " " + sub { + case "project create": + return a.projectCreate(ctx, rest) + case "project list": + return a.projectList(ctx) + case "user create": + return a.userCreate(ctx, rest) + case "user revoke": + return a.userRevoke(ctx, rest) + case "user list": + return a.userList(ctx, rest) + case "submission retry": + return a.submissionRetry(ctx, rest) + default: + usage() + return fmt.Errorf("unknown command %q", group+" "+sub) + } +} + +type app struct { + cfg *config.Config + st *store.Store +} + +func (a *app) github() (*github.Client, error) { + if !a.cfg.GitHub.Configured() { + return nil, errors.New("GitHub App not configured (set GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY_FILE)") + } + return github.New(a.cfg.GitHub.AppID, a.cfg.GitHub.InstallationID, a.cfg.GitHub.PrivateKeyPEM, + a.cfg.GitHub.APIBase, a.cfg.GitHub.GraphQLURL) +} + +func (a *app) projectCreate(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("project create", flag.ContinueOnError) + slug := fs.String("slug", "", "unique project slug") + name := fs.String("name", "", "display name shown to submitters") + owner := fs.String("owner", "", "GitHub org/user that owns the repo + board") + repo := fs.String("repo", "", "GitHub repository for issues") + nodeID := fs.String("project-node-id", "", "ProjectV2 node id (PVT_...); skips lookup") + number := fs.Int("project-number", 0, "ProjectV2 number (resolved via the GitHub App)") + if err := fs.Parse(args); err != nil { + return err + } + if *slug == "" || *name == "" || *owner == "" || *repo == "" { + return errors.New("--slug, --name, --owner and --repo are required") + } + + projectID := *nodeID + if projectID == "" { + if *number == 0 { + return errors.New("provide --project-node-id or --project-number") + } + gh, err := a.github() + if err != nil { + return err + } + projectID, err = gh.ResolveProjectNodeID(ctx, *owner, *number) + if err != nil { + return err + } + } + + p, err := a.st.CreateProject(ctx, *slug, *name, *owner, *repo, projectID) + if err != nil { + return err + } + fmt.Printf("created project %s (%s)\n board node id: %s\n", p.Slug, p.ID, p.GitHubProjectID) + return nil +} + +func (a *app) projectList(ctx context.Context) error { + projects, err := a.st.ListProjects(ctx) + if err != nil { + return err + } + tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "SLUG\tNAME\tREPO\tBOARD") + for _, p := range projects { + fmt.Fprintf(tw, "%s\t%s\t%s/%s\t%s\n", p.Slug, p.Name, p.GitHubOwner, p.GitHubRepo, p.GitHubProjectID) + } + return tw.Flush() +} + +func (a *app) userCreate(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("user create", flag.ContinueOnError) + projectSlug := fs.String("project", "", "project slug") + name := fs.String("name", "", "submitter display name") + email := fs.String("email", "", "optional email for reference") + if err := fs.Parse(args); err != nil { + return err + } + if *projectSlug == "" || *name == "" { + return errors.New("--project and --name are required") + } + + project, err := a.st.GetProjectBySlug(ctx, *projectSlug) + if err != nil { + return fmt.Errorf("project %q: %w", *projectSlug, err) + } + var emailPtr *string + if *email != "" { + emailPtr = email + } + + code := authcode.New() + if _, err := a.st.CreateUser(ctx, code, project.ID, *name, emailPtr); err != nil { + return err + } + fmt.Printf("created invite for %s on %s\n auth code: %s\n", *name, project.Slug, code) + return nil +} + +func (a *app) userRevoke(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("user revoke", flag.ContinueOnError) + code := fs.String("code", "", "auth code to revoke") + if err := fs.Parse(args); err != nil { + return err + } + if *code == "" { + return errors.New("--code is required") + } + if err := a.st.RevokeUser(ctx, authcode.Normalize(*code)); err != nil { + return err + } + fmt.Println("revoked") + return nil +} + +func (a *app) userList(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("user list", flag.ContinueOnError) + projectSlug := fs.String("project", "", "project slug") + if err := fs.Parse(args); err != nil { + return err + } + if *projectSlug == "" { + return errors.New("--project is required") + } + project, err := a.st.GetProjectBySlug(ctx, *projectSlug) + if err != nil { + return err + } + users, err := a.st.ListUsers(ctx, project.ID) + if err != nil { + return err + } + tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "CODE\tNAME\tEMAIL\tSTATUS") + for _, u := range users { + status := "active" + if u.RevokedAt != nil { + status = "revoked" + } + email := "" + if u.Email != nil { + email = *u.Email + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", u.AuthCode, u.DisplayName, email, status) + } + return tw.Flush() +} + +func (a *app) submissionRetry(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("submission retry", flag.ContinueOnError) + limit := fs.Int("limit", 50, "max submissions to retry") + if err := fs.Parse(args); err != nil { + return err + } + gh, err := a.github() + if err != nil { + return err + } + subs, err := a.st.ListUnpublished(ctx, *limit) + if err != nil { + return err + } + if len(subs) == 0 { + fmt.Println("nothing to retry") + return nil + } + + var ok, failed int + for _, sub := range subs { + project, err := a.st.GetProjectByID(ctx, sub.ProjectID) + if err != nil { + fmt.Fprintf(os.Stderr, "submission %s: project lookup failed: %v\n", sub.ID, err) + failed++ + continue + } + user, err := a.st.GetUserByCode(ctx, sub.AuthCode) + name := sub.AuthCode + if err == nil { + name = user.DisplayName + } + body := "**Submitted by:** " + name + "\n\n---\n\n" + sub.Body + res, err := gh.CreateIssueOnBoard(ctx, project.GitHubOwner, project.GitHubRepo, project.GitHubProjectID, sub.Title, body) + if err != nil { + _ = a.st.MarkSubmissionFailed(ctx, sub.ID, err.Error()) + fmt.Fprintf(os.Stderr, "submission %s: %v\n", sub.ID, err) + failed++ + continue + } + if err := a.st.MarkSubmissionCreated(ctx, sub.ID, res.IssueNumber, res.IssueURL, res.IssueNodeID); err != nil { + fmt.Fprintf(os.Stderr, "submission %s: created issue but DB update failed: %v\n", sub.ID, err) + } + ok++ + } + fmt.Printf("retried: %d succeeded, %d failed\n", ok, failed) + return nil +} + +func usage() { + fmt.Fprintln(os.Stderr, `idea-collect admin CLI + +Commands: + project create --slug --name --owner --repo (--project-node-id | --project-number) + project list + user create --project --name [--email ] + user revoke --code + user list --project + submission retry [--limit N]`) +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..72e1e63 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,88 @@ +// Command server runs the idea-collect HTTP API. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jasonross/idea-collect/backend/internal/api" + "github.com/jasonross/idea-collect/backend/internal/config" + "github.com/jasonross/idea-collect/backend/internal/github" + "github.com/jasonross/idea-collect/backend/internal/ratelimit" + "github.com/jasonross/idea-collect/backend/internal/session" + "github.com/jasonross/idea-collect/backend/internal/store" +) + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(log) + + if err := run(log); err != nil { + log.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(log *slog.Logger) error { + cfg, err := config.Load() + if err != nil { + return err + } + if err := cfg.RequireServer(); err != nil { + return err + } + + ctx := context.Background() + st, err := store.New(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer st.Close() + if err := st.Migrate(ctx); err != nil { + return err + } + log.Info("migrations applied") + + gh, err := github.New(cfg.GitHub.AppID, cfg.GitHub.InstallationID, cfg.GitHub.PrivateKeyPEM, + cfg.GitHub.APIBase, cfg.GitHub.GraphQLURL) + if err != nil { + return err + } + + sm := session.NewManager(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure) + rl := ratelimit.New(cfg.AuthRatePerMinute, cfg.AuthRateBurst) + defer rl.Close() + + srv := api.NewServer(st, sm, rl, gh, cfg.AllowedOrigins, log) + + httpServer := &http.Server{ + Addr: cfg.ListenAddr, + Handler: srv.Handler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Info("listening", "addr", cfg.ListenAddr) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("server error", "err", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + log.Info("shutting down") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return httpServer.Shutdown(shutdownCtx) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..64786c9 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,18 @@ +module github.com/jasonross/idea-collect/backend + +go 1.26 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/jackc/pgx/v5 v5.7.2 + golang.org/x/time v0.9.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..13f72f3 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go new file mode 100644 index 0000000..9b5393a --- /dev/null +++ b/backend/internal/api/api.go @@ -0,0 +1,229 @@ +// Package api wires the HTTP handlers for the public submission flow. +package api + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + + "github.com/jasonross/idea-collect/backend/internal/authcode" + "github.com/jasonross/idea-collect/backend/internal/github" + "github.com/jasonross/idea-collect/backend/internal/ratelimit" + "github.com/jasonross/idea-collect/backend/internal/session" + "github.com/jasonross/idea-collect/backend/internal/store" +) + +const ( + maxTitleLen = 200 + maxBodyLen = 5000 +) + +// Publisher creates a GitHub issue and adds it to a project board. +type Publisher interface { + CreateIssueOnBoard(ctx context.Context, owner, repo, projectNodeID, title, body string) (github.Result, error) +} + +type Server struct { + store *store.Store + sessions *session.Manager + limiter *ratelimit.Limiter + publisher Publisher + origins map[string]bool + log *slog.Logger +} + +func NewServer(st *store.Store, sm *session.Manager, rl *ratelimit.Limiter, pub Publisher, allowedOrigins []string, log *slog.Logger) *Server { + origins := make(map[string]bool, len(allowedOrigins)) + for _, o := range allowedOrigins { + origins[o] = true + } + return &Server{store: st, sessions: sm, limiter: rl, publisher: pub, origins: origins, log: log} +} + +// Handler returns the fully-wrapped HTTP handler. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.handleHealth) + mux.HandleFunc("POST /api/auth", s.handleAuth) + mux.HandleFunc("GET /api/session", s.handleSession) + mux.HandleFunc("POST /api/submissions", s.handleSubmit) + mux.HandleFunc("POST /api/logout", s.handleLogout) + return s.recoverer(s.cors(s.requestLog(mux))) +} + +// ---- handlers ---- + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +type authRequest struct { + AuthCode string `json:"auth_code"` +} +type authResponse struct { + ProjectName string `json:"project_name"` + DisplayName string `json:"display_name"` +} + +func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if !s.limiter.Allow(ip) { + writeError(w, http.StatusTooManyRequests, "too many attempts, please wait and try again") + return + } + + var req authRequest + if !decodeJSON(w, r, &req) { + return + } + code := authcode.Normalize(req.AuthCode) + if code == "" { + writeError(w, http.StatusBadRequest, "please enter your invite code") + return + } + + user, project, err := s.store.GetActiveUser(r.Context(), code) + if err != nil { + // fail2ban-parseable line keyed on the real client IP. + s.log.Warn("auth failure", "event", "auth_failed", "ip", ip) + writeError(w, http.StatusUnauthorized, "that invite code is not valid") + return + } + + s.sessions.Issue(w, user.AuthCode, project.ID, user.DisplayName) + writeJSON(w, http.StatusOK, authResponse{ProjectName: project.Name, DisplayName: user.DisplayName}) +} + +type sessionResponse struct { + ProjectName string `json:"project_name"` + DisplayName string `json:"display_name"` +} + +func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { + sess, err := s.sessions.Verify(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "not signed in") + return + } + project, err := s.store.GetProjectByID(r.Context(), sess.ProjectID) + if err != nil { + writeError(w, http.StatusUnauthorized, "not signed in") + return + } + writeJSON(w, http.StatusOK, sessionResponse{ProjectName: project.Name, DisplayName: sess.DisplayName}) +} + +type submitRequest struct { + Title string `json:"title"` + Body string `json:"body"` +} +type submitResponse struct { + IssueURL string `json:"issue_url"` + IssueNumber int `json:"issue_number"` +} + +func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) { + sess, err := s.sessions.Verify(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "please enter your invite code first") + return + } + + var req submitRequest + if !decodeJSON(w, r, &req) { + return + } + title := strings.TrimSpace(req.Title) + body := strings.TrimSpace(req.Body) + if title == "" || body == "" { + writeError(w, http.StatusBadRequest, "please fill in both a title and a description") + return + } + if len(title) > maxTitleLen || len(body) > maxBodyLen { + writeError(w, http.StatusBadRequest, "your idea is too long") + return + } + + project, err := s.store.GetProjectByID(r.Context(), sess.ProjectID) + if err != nil { + writeError(w, http.StatusUnauthorized, "please enter your invite code first") + return + } + + subID, err := s.store.CreateSubmission(r.Context(), sess.AuthCode, project.ID, title, body) + if err != nil { + s.log.Error("create submission", "err", err) + writeError(w, http.StatusInternalServerError, "could not save your idea, please try again") + return + } + + issueBody := composeIssueBody(sess.DisplayName, body) + res, err := s.publisher.CreateIssueOnBoard(r.Context(), + project.GitHubOwner, project.GitHubRepo, project.GitHubProjectID, title, issueBody) + if err != nil { + _ = s.store.MarkSubmissionFailed(r.Context(), subID, err.Error()) + s.log.Error("publish submission", "id", subID, "err", err) + writeError(w, http.StatusBadGateway, "we saved your idea but couldn't post it yet; we'll retry shortly") + return + } + + if err := s.store.MarkSubmissionCreated(r.Context(), subID, res.IssueNumber, res.IssueURL, res.IssueNodeID); err != nil { + s.log.Error("mark submission created", "id", subID, "err", err) + } + writeJSON(w, http.StatusCreated, submitResponse{IssueURL: res.IssueURL, IssueNumber: res.IssueNumber}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + s.sessions.Clear(w) + w.WriteHeader(http.StatusNoContent) +} + +// composeIssueBody puts the submitter's name in the issue body. +func composeIssueBody(name, body string) string { + var b strings.Builder + b.WriteString("**Submitted by:** ") + b.WriteString(name) + b.WriteString("\n\n---\n\n") + b.WriteString(body) + return b.String() +} + +// ---- small helpers ---- + +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func clientIP(r *http.Request) string { + // Backend is only reachable through Caddy on an internal network, so the + // left-most X-Forwarded-For entry is the real client. + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + host := r.RemoteAddr + if i := strings.LastIndexByte(host, ':'); i >= 0 { + host = host[:i] + } + return host +} diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go new file mode 100644 index 0000000..784dc21 --- /dev/null +++ b/backend/internal/api/api_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/jasonross/idea-collect/backend/internal/ratelimit" + "github.com/jasonross/idea-collect/backend/internal/session" +) + +func testServer(t *testing.T, perMinute float64, burst int) *Server { + t.Helper() + sm := session.NewManager([]byte("test-secret-at-least-16-bytes"), time.Hour, false) + rl := ratelimit.New(perMinute, burst) + t.Cleanup(rl.Close) + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + // store and publisher are nil: the paths under test return before touching them. + return NewServer(nil, sm, rl, nil, []string{"http://localhost:5173"}, log) +} + +func TestComposeIssueBody(t *testing.T) { + got := composeIssueBody("Jane Doe", "Add dark mode") + if !strings.Contains(got, "**Submitted by:** Jane Doe") || !strings.Contains(got, "Add dark mode") { + t.Errorf("unexpected body: %q", got) + } +} + +func TestClientIPPrefersForwardedFor(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.0.0.1:5000" + r.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1") + if ip := clientIP(r); ip != "203.0.113.7" { + t.Errorf("got %q, want 203.0.113.7", ip) + } +} + +func TestAuthEmptyCodeIsBadRequest(t *testing.T) { + s := testServer(t, 100, 100) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth", strings.NewReader(`{"auth_code":" "}`)) + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("got %d, want 400", rec.Code) + } +} + +func TestAuthRateLimited(t *testing.T) { + s := testServer(t, 1, 2) // burst of 2 + do := func() int { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth", strings.NewReader(`{"auth_code":""}`)) + req.RemoteAddr = "198.51.100.5:1234" + s.Handler().ServeHTTP(rec, req) + return rec.Code + } + _ = do() + _ = do() + if code := do(); code != http.StatusTooManyRequests { + t.Errorf("third attempt: got %d, want 429", code) + } +} + +func TestSubmitWithoutSessionUnauthorized(t *testing.T) { + s := testServer(t, 100, 100) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/submissions", strings.NewReader(`{"title":"t","body":"b"}`)) + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("got %d, want 401", rec.Code) + } +} + +func TestCORSPreflight(t *testing.T) { + s := testServer(t, 100, 100) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/api/auth", nil) + req.Header.Set("Origin", "http://localhost:5173") + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("preflight got %d, want 204", rec.Code) + } + if rec.Header().Get("Access-Control-Allow-Origin") != "http://localhost:5173" { + t.Errorf("missing CORS allow-origin header") + } +} diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go new file mode 100644 index 0000000..c08315b --- /dev/null +++ b/backend/internal/api/middleware.go @@ -0,0 +1,67 @@ +package api + +import ( + "net/http" + "time" +) + +// cors allows the configured SPA origins with credentials and answers preflight. +func (s *Server) cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && s.origins[origin] { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Credentials", "true") + h.Set("Vary", "Origin") + if r.Method == http.MethodOptions { + h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + h.Set("Access-Control-Allow-Headers", "Content-Type") + h.Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return + } + } else if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (sr *statusRecorder) WriteHeader(code int) { + sr.status = code + sr.ResponseWriter.WriteHeader(code) +} + +func (s *Server) requestLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + s.log.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "ip", clientIP(r), + "dur_ms", time.Since(start).Milliseconds(), + ) + }) +} + +func (s *Server) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + s.log.Error("panic recovered", "err", rec, "path", r.URL.Path) + writeError(w, http.StatusInternalServerError, "something went wrong") + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/authcode/authcode.go b/backend/internal/authcode/authcode.go new file mode 100644 index 0000000..a6a171c --- /dev/null +++ b/backend/internal/authcode/authcode.go @@ -0,0 +1,62 @@ +// Package authcode generates human-typeable, high-entropy invite/login codes. +package authcode + +import ( + "crypto/rand" + "math/big" + "strings" +) + +// Crockford base32 alphabet (no I, L, O, U — avoids ambiguity for non-technical users). +const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// groups x size = number of significant characters. 12 chars of base32 ≈ 60 bits of entropy. +const ( + groups = 3 + groupSize = 4 +) + +// New returns a fresh code formatted as XXXX-XXXX-XXXX using crypto/rand. +func New() string { + var sb strings.Builder + sb.Grow(groups*groupSize + (groups - 1)) + for g := 0; g < groups; g++ { + if g > 0 { + sb.WriteByte('-') + } + for i := 0; i < groupSize; i++ { + sb.WriteByte(alphabet[randIndex(len(alphabet))]) + } + } + return sb.String() +} + +// Normalize upper-cases, strips separators/whitespace, and maps Crockford's +// interchangeable characters (I/L→1, O→0) so user-entered codes match storage. +func Normalize(s string) string { + var sb strings.Builder + for _, r := range strings.ToUpper(strings.TrimSpace(s)) { + switch r { + case '-', ' ', '\t': + continue + case 'I', 'L': + sb.WriteRune('1') + case 'O': + sb.WriteRune('0') + case 'U': + sb.WriteRune('V') + default: + sb.WriteRune(r) + } + } + return sb.String() +} + +func randIndex(n int) int { + v, err := rand.Int(rand.Reader, big.NewInt(int64(n))) + if err != nil { + // crypto/rand should never fail; if it does, fail loudly rather than emit a weak code. + panic("authcode: crypto/rand failure: " + err.Error()) + } + return int(v.Int64()) +} diff --git a/backend/internal/authcode/authcode_test.go b/backend/internal/authcode/authcode_test.go new file mode 100644 index 0000000..373d767 --- /dev/null +++ b/backend/internal/authcode/authcode_test.go @@ -0,0 +1,42 @@ +package authcode + +import ( + "regexp" + "testing" +) + +var formatRE = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$`) + +func TestNewFormat(t *testing.T) { + for i := 0; i < 1000; i++ { + c := New() + if !formatRE.MatchString(c) { + t.Fatalf("code %q does not match expected format", c) + } + } +} + +func TestNewIsRandom(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 1000; i++ { + c := New() + if seen[c] { + t.Fatalf("duplicate code generated: %q", c) + } + seen[c] = true + } +} + +func TestNormalize(t *testing.T) { + tests := []struct{ in, want string }{ + {"abcd-1234-wxyz", "ABCD1234WXYZ"}, + {" ABCD 1234 WXYZ ", "ABCD1234WXYZ"}, + {"oiln-uuuu", "011NVVVV"}, // O->0, I->1, L->1, N stays, U->V + {"AB-CD", "ABCD"}, + } + for _, tc := range tests { + if got := Normalize(tc.in); got != tc.want { + t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..0730ffa --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,149 @@ +// Package config loads runtime configuration from environment variables. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + DatabaseURL string + + ListenAddr string + AllowedOrigins []string + CookieSecure bool + SessionTTL time.Duration + SessionSecret []byte + + AuthRatePerMinute float64 + AuthRateBurst int + + GitHub GitHubConfig +} + +type GitHubConfig struct { + AppID int64 + InstallationID int64 + PrivateKeyPEM []byte + APIBase string // default https://api.github.com + GraphQLURL string // default https://api.github.com/graphql +} + +// Configured reports whether enough GitHub settings are present to publish issues. +func (g GitHubConfig) Configured() bool { + return g.AppID != 0 && g.InstallationID != 0 && len(g.PrivateKeyPEM) > 0 +} + +// Load reads configuration from the environment, applying defaults. It returns an +// error only for values that are present but malformed; missing GitHub settings are +// tolerated so DB-only tooling (admin CLI) can run without them. +func Load() (*Config, error) { + c := &Config{ + DatabaseURL: getenv("DATABASE_URL", "postgres://idea:idea@localhost:5432/idea?sslmode=disable"), + ListenAddr: getenv("LISTEN_ADDR", ":8080"), + AllowedOrigins: splitAndTrim(getenv("ALLOWED_ORIGINS", "")), + CookieSecure: getenvBool("COOKIE_SECURE", false), + SessionTTL: getenvDuration("SESSION_TTL", 2*time.Hour), + SessionSecret: []byte(getenv("SESSION_SECRET", "")), + AuthRatePerMinute: getenvFloat("AUTH_RATE_PER_MINUTE", 10), + AuthRateBurst: getenvInt("AUTH_RATE_BURST", 5), + } + + gh := GitHubConfig{ + AppID: getenvInt64("GITHUB_APP_ID", 0), + InstallationID: getenvInt64("GITHUB_APP_INSTALLATION_ID", 0), + APIBase: strings.TrimRight(getenv("GITHUB_API_BASE", "https://api.github.com"), "/"), + GraphQLURL: getenv("GITHUB_GRAPHQL_URL", "https://api.github.com/graphql"), + } + if keyFile := getenv("GITHUB_APP_PRIVATE_KEY_FILE", ""); keyFile != "" { + pem, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("reading GITHUB_APP_PRIVATE_KEY_FILE: %w", err) + } + gh.PrivateKeyPEM = pem + } + c.GitHub = gh + + return c, nil +} + +// RequireServer validates settings that the HTTP server cannot run without. +func (c *Config) RequireServer() error { + if len(c.SessionSecret) < 16 { + return fmt.Errorf("SESSION_SECRET must be at least 16 bytes") + } + if !c.GitHub.Configured() { + return fmt.Errorf("GitHub App not configured (need GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY_FILE)") + } + return nil +} + +func getenv(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +func splitAndTrim(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + 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 getenvBool(key string, def bool) bool { + if v, ok := os.LookupEnv(key); ok { + b, err := strconv.ParseBool(strings.TrimSpace(v)) + if err == nil { + return b + } + } + return def +} + +func getenvInt(key string, def int) int { + if v, ok := os.LookupEnv(key); ok { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return n + } + } + return def +} + +func getenvInt64(key string, def int64) int64 { + if v, ok := os.LookupEnv(key); ok { + if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil { + return n + } + } + return def +} + +func getenvFloat(key string, def float64) float64 { + if v, ok := os.LookupEnv(key); ok { + if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + return f + } + } + return def +} + +func getenvDuration(key string, def time.Duration) time.Duration { + if v, ok := os.LookupEnv(key); ok { + if d, err := time.ParseDuration(strings.TrimSpace(v)); err == nil { + return d + } + } + return def +} diff --git a/backend/internal/github/github.go b/backend/internal/github/github.go new file mode 100644 index 0000000..a3d7663 --- /dev/null +++ b/backend/internal/github/github.go @@ -0,0 +1,251 @@ +// Package github talks to GitHub as a GitHub App: it mints an installation token, +// creates issues via REST, and adds them to a Projects v2 board via GraphQL. +package github + +import ( + "bytes" + "context" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Result is the outcome of publishing a submission to GitHub. +type Result struct { + IssueNumber int + IssueURL string + IssueNodeID string +} + +type Client struct { + appID int64 + installationID int64 + signKey *rsa.PrivateKey + apiBase string + graphqlURL string + http *http.Client + + mu sync.Mutex + token string + tokenExp time.Time +} + +// New builds a Client from the GitHub App credentials. +func New(appID, installationID int64, privateKeyPEM []byte, apiBase, graphqlURL string) (*Client, error) { + key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM) + if err != nil { + return nil, fmt.Errorf("parsing app private key: %w", err) + } + if apiBase == "" { + apiBase = "https://api.github.com" + } + if graphqlURL == "" { + graphqlURL = "https://api.github.com/graphql" + } + return &Client{ + appID: appID, + installationID: installationID, + signKey: key, + apiBase: apiBase, + graphqlURL: graphqlURL, + http: &http.Client{Timeout: 15 * time.Second}, + }, nil +} + +// CreateIssueOnBoard creates an issue in owner/repo and adds it to the given +// ProjectV2 (node id). The project step is best-effort-reported via the error. +func (c *Client) CreateIssueOnBoard(ctx context.Context, owner, repo, projectNodeID, title, body string) (Result, error) { + token, err := c.installationToken(ctx) + if err != nil { + return Result{}, err + } + + res, err := c.createIssue(ctx, token, owner, repo, title, body) + if err != nil { + return Result{}, err + } + if err := c.addToProject(ctx, token, projectNodeID, res.IssueNodeID); err != nil { + // Issue exists; surface the board failure so the caller can retry that step. + return res, fmt.Errorf("issue #%d created but adding to board failed: %w", res.IssueNumber, err) + } + return res, nil +} + +// ResolveProjectNodeID finds the ProjectV2 node id for an org or user project number. +func (c *Client) ResolveProjectNodeID(ctx context.Context, login string, number int) (string, error) { + token, err := c.installationToken(ctx) + if err != nil { + return "", err + } + query := `query($login:String!,$number:Int!){ + organization(login:$login){ projectV2(number:$number){ id } } + user(login:$login){ projectV2(number:$number){ id } } + }` + var resp struct { + Data struct { + Organization *struct { + ProjectV2 *struct{ ID string } `json:"projectV2"` + } `json:"organization"` + User *struct { + ProjectV2 *struct{ ID string } `json:"projectV2"` + } `json:"user"` + } `json:"data"` + Errors []graphqlError `json:"errors"` + } + if err := c.graphql(ctx, token, query, map[string]any{"login": login, "number": number}, &resp); err != nil { + return "", err + } + if resp.Data.Organization != nil && resp.Data.Organization.ProjectV2 != nil { + return resp.Data.Organization.ProjectV2.ID, nil + } + if resp.Data.User != nil && resp.Data.User.ProjectV2 != nil { + return resp.Data.User.ProjectV2.ID, nil + } + return "", fmt.Errorf("project number %d not found for %q", number, login) +} + +func (c *Client) createIssue(ctx context.Context, token, owner, repo, title, body string) (Result, error) { + payload, _ := json.Marshal(map[string]string{"title": title, "body": body}) + url := fmt.Sprintf("%s/repos/%s/%s/issues", c.apiBase, owner, repo) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return Result{}, err + } + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return Result{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return Result{}, apiError("create issue", resp) + } + var out struct { + Number int `json:"number"` + HTMLURL string `json:"html_url"` + NodeID string `json:"node_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return Result{}, fmt.Errorf("decoding issue response: %w", err) + } + return Result{IssueNumber: out.Number, IssueURL: out.HTMLURL, IssueNodeID: out.NodeID}, nil +} + +func (c *Client) addToProject(ctx context.Context, token, projectNodeID, contentNodeID string) error { + mutation := `mutation($projectId:ID!,$contentId:ID!){ + addProjectV2ItemById(input:{projectId:$projectId,contentId:$contentId}){ item { id } } + }` + var resp struct { + Errors []graphqlError `json:"errors"` + } + return c.graphql(ctx, token, mutation, + map[string]any{"projectId": projectNodeID, "contentId": contentNodeID}, &resp) +} + +// installationToken returns a cached installation access token, refreshing when stale. +func (c *Client) installationToken(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.token != "" && time.Now().Before(c.tokenExp.Add(-time.Minute)) { + return c.token, nil + } + + appJWT, err := c.appJWT() + if err != nil { + return "", err + } + url := fmt.Sprintf("%s/app/installations/%d/access_tokens", c.apiBase, c.installationID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+appJWT) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return "", apiError("installation token", resp) + } + var out struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("decoding token response: %w", err) + } + c.token, c.tokenExp = out.Token, out.ExpiresAt + return c.token, nil +} + +func (c *Client) appJWT() (string, error) { + now := time.Now() + claims := jwt.RegisteredClaims{ + Issuer: strconv.FormatInt(c.appID, 10), + IssuedAt: jwt.NewNumericDate(now.Add(-30 * time.Second)), + ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)), + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return tok.SignedString(c.signKey) +} + +func (c *Client) graphql(ctx context.Context, token, query string, vars map[string]any, out any) error { + payload, _ := json.Marshal(map[string]any{"query": query, "variables": vars}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return apiError("graphql", resp) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("decoding graphql response: %w", err) + } + // Surface top-level GraphQL errors if the out struct exposes them. + if errs := extractErrors(body); len(errs) > 0 { + return fmt.Errorf("graphql error: %s", errs[0].Message) + } + return nil +} + +type graphqlError struct { + Message string `json:"message"` +} + +func extractErrors(body []byte) []graphqlError { + var e struct { + Errors []graphqlError `json:"errors"` + } + _ = json.Unmarshal(body, &e) + return e.Errors +} + +func apiError(op string, resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("github %s: status %d: %s", op, resp.StatusCode, bytes.TrimSpace(body)) +} diff --git a/backend/internal/github/github_test.go b/backend/internal/github/github_test.go new file mode 100644 index 0000000..37c0a43 --- /dev/null +++ b/backend/internal/github/github_test.go @@ -0,0 +1,116 @@ +package github + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testKeyPEM(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) +} + +func TestCreateIssueOnBoard(t *testing.T) { + var gotIssueBody map[string]string + var gotGraphQL map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/access_tokens"): + if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") { + t.Errorf("token request missing app JWT bearer: %q", got) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "ghs_installationtoken", + "expires_at": "2999-01-01T00:00:00Z", + }) + case strings.HasSuffix(r.URL.Path, "/issues"): + if got := r.Header.Get("Authorization"); got != "token ghs_installationtoken" { + t.Errorf("issue request wrong auth: %q", got) + } + _ = json.NewDecoder(r.Body).Decode(&gotIssueBody) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "number": 42, + "html_url": "https://github.com/o/r/issues/42", + "node_id": "I_issuenode", + }) + case strings.HasSuffix(r.URL.Path, "/graphql"): + _ = json.NewDecoder(r.Body).Decode(&gotGraphQL) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "addProjectV2ItemById": map[string]any{ + "item": map[string]any{"id": "PVTI_item"}, + }, + }, + }) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + c, err := New(123, 456, testKeyPEM(t), srv.URL, srv.URL+"/graphql") + if err != nil { + t.Fatal(err) + } + + res, err := c.CreateIssueOnBoard(context.Background(), "o", "r", "PVT_board", "My idea", "body text") + if err != nil { + t.Fatalf("CreateIssueOnBoard: %v", err) + } + if res.IssueNumber != 42 || res.IssueNodeID != "I_issuenode" { + t.Errorf("unexpected result: %+v", res) + } + if gotIssueBody["title"] != "My idea" || gotIssueBody["body"] != "body text" { + t.Errorf("issue payload mismatch: %+v", gotIssueBody) + } + vars, _ := gotGraphQL["variables"].(map[string]any) + if vars["projectId"] != "PVT_board" || vars["contentId"] != "I_issuenode" { + t.Errorf("graphql variables mismatch: %+v", vars) + } +} + +func TestGraphQLErrorSurfaced(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/access_tokens") { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"token": "t", "expires_at": "2999-01-01T00:00:00Z"}) + return + } + if strings.HasSuffix(r.URL.Path, "/issues") { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"number": 1, "html_url": "u", "node_id": "n"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{"message": "Projects v2 not enabled"}}, + }) + })) + defer srv.Close() + + c, err := New(1, 2, testKeyPEM(t), srv.URL, srv.URL+"/graphql") + if err != nil { + t.Fatal(err) + } + _, err = c.CreateIssueOnBoard(context.Background(), "o", "r", "PVT", "t", "b") + if err == nil || !strings.Contains(err.Error(), "adding to board failed") { + t.Errorf("expected board failure error, got %v", err) + } +} diff --git a/backend/internal/ratelimit/ratelimit.go b/backend/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..d9e0e0d --- /dev/null +++ b/backend/internal/ratelimit/ratelimit.go @@ -0,0 +1,82 @@ +// Package ratelimit provides a simple per-key (per-IP) token-bucket limiter, +// used to throttle auth-code attempts and slow brute-force guessing. +package ratelimit + +import ( + "sync" + "time" + + "golang.org/x/time/rate" +) + +type visitor struct { + limiter *rate.Limiter + lastSeen time.Time +} + +type Limiter struct { + mu sync.Mutex + visitors map[string]*visitor + rate rate.Limit + burst int + ttl time.Duration + now func() time.Time + stop chan struct{} +} + +// New builds a limiter allowing perMinute requests per key with the given burst. +func New(perMinute float64, burst int) *Limiter { + if perMinute <= 0 { + perMinute = 1 + } + if burst < 1 { + burst = 1 + } + l := &Limiter{ + visitors: make(map[string]*visitor), + rate: rate.Limit(perMinute / 60.0), + burst: burst, + ttl: 10 * time.Minute, + now: time.Now, + stop: make(chan struct{}), + } + go l.janitor() + return l +} + +// Allow reports whether a request for key may proceed now. +func (l *Limiter) Allow(key string) bool { + l.mu.Lock() + v, ok := l.visitors[key] + if !ok { + v = &visitor{limiter: rate.NewLimiter(l.rate, l.burst)} + l.visitors[key] = v + } + v.lastSeen = l.now() + lim := v.limiter + l.mu.Unlock() + return lim.Allow() +} + +// Close stops the background cleanup goroutine. +func (l *Limiter) Close() { close(l.stop) } + +func (l *Limiter) janitor() { + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + select { + case <-l.stop: + return + case <-t.C: + cutoff := l.now().Add(-l.ttl) + l.mu.Lock() + for k, v := range l.visitors { + if v.lastSeen.Before(cutoff) { + delete(l.visitors, k) + } + } + l.mu.Unlock() + } + } +} diff --git a/backend/internal/ratelimit/ratelimit_test.go b/backend/internal/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..baf1415 --- /dev/null +++ b/backend/internal/ratelimit/ratelimit_test.go @@ -0,0 +1,32 @@ +package ratelimit + +import "testing" + +func TestAllowConsumesBurstThenBlocks(t *testing.T) { + l := New(60, 3) // 1/sec, burst 3 + defer l.Close() + + for i := 0; i < 3; i++ { + if !l.Allow("1.2.3.4") { + t.Fatalf("request %d within burst should be allowed", i) + } + } + if l.Allow("1.2.3.4") { + t.Errorf("4th immediate request should be blocked") + } +} + +func TestAllowIsPerKey(t *testing.T) { + l := New(60, 1) + defer l.Close() + + if !l.Allow("a") { + t.Fatal("first key-a request should be allowed") + } + if !l.Allow("b") { + t.Fatal("first key-b request should be allowed (independent bucket)") + } + if l.Allow("a") { + t.Error("second key-a request should be blocked") + } +} diff --git a/backend/internal/session/session.go b/backend/internal/session/session.go new file mode 100644 index 0000000..c036244 --- /dev/null +++ b/backend/internal/session/session.go @@ -0,0 +1,115 @@ +// Package session issues and verifies stateless, HMAC-signed session cookies. +// A cookie carries the authenticated auth_code + project so the SPA can submit +// multiple ideas after entering its code once. +package session + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strings" + "time" +) + +const CookieName = "idc_session" + +var ( + ErrNoCookie = errors.New("no session cookie") + ErrInvalid = errors.New("invalid session cookie") + ErrExpired = errors.New("session expired") +) + +// Session is the data carried inside the signed cookie. +type Session struct { + AuthCode string `json:"c"` + ProjectID string `json:"p"` + DisplayName string `json:"n"` + ExpiresAt int64 `json:"e"` // unix seconds +} + +type Manager struct { + secret []byte + ttl time.Duration + secure bool + now func() time.Time // overridable in tests +} + +func NewManager(secret []byte, ttl time.Duration, secure bool) *Manager { + return &Manager{secret: secret, ttl: ttl, secure: secure, now: time.Now} +} + +// Issue signs a session for the given identity and writes it as a cookie. +func (m *Manager) Issue(w http.ResponseWriter, authCode, projectID, displayName string) { + exp := m.now().Add(m.ttl) + s := Session{AuthCode: authCode, ProjectID: projectID, DisplayName: displayName, ExpiresAt: exp.Unix()} + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: m.encode(s), + Path: "/", + Expires: exp, + MaxAge: int(m.ttl.Seconds()), + HttpOnly: true, + Secure: m.secure, + SameSite: http.SameSiteStrictMode, + }) +} + +// Clear expires the session cookie. +func (m *Manager) Clear(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: m.secure, + SameSite: http.SameSiteStrictMode, + }) +} + +// Verify reads and validates the session cookie from the request. +func (m *Manager) Verify(r *http.Request) (Session, error) { + c, err := r.Cookie(CookieName) + if err != nil { + return Session{}, ErrNoCookie + } + return m.decode(c.Value) +} + +func (m *Manager) encode(s Session) string { + payload, _ := json.Marshal(s) + b64 := base64.RawURLEncoding.EncodeToString(payload) + return b64 + "." + base64.RawURLEncoding.EncodeToString(m.mac([]byte(b64))) +} + +func (m *Manager) decode(token string) (Session, error) { + b64, sig, ok := strings.Cut(token, ".") + if !ok { + return Session{}, ErrInvalid + } + gotMac, err := base64.RawURLEncoding.DecodeString(sig) + if err != nil || !hmac.Equal(gotMac, m.mac([]byte(b64))) { + return Session{}, ErrInvalid + } + payload, err := base64.RawURLEncoding.DecodeString(b64) + if err != nil { + return Session{}, ErrInvalid + } + var s Session + if err := json.Unmarshal(payload, &s); err != nil { + return Session{}, ErrInvalid + } + if m.now().Unix() >= s.ExpiresAt { + return Session{}, ErrExpired + } + return s, nil +} + +func (m *Manager) mac(b []byte) []byte { + h := hmac.New(sha256.New, m.secret) + h.Write(b) + return h.Sum(nil) +} diff --git a/backend/internal/session/session_test.go b/backend/internal/session/session_test.go new file mode 100644 index 0000000..53c7969 --- /dev/null +++ b/backend/internal/session/session_test.go @@ -0,0 +1,91 @@ +package session + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func newReqWithCookie(value string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: CookieName, Value: value}) + return r +} + +func TestIssueAndVerifyRoundTrip(t *testing.T) { + m := NewManager([]byte("test-secret-at-least-16-bytes"), time.Hour, true) + rec := httptest.NewRecorder() + m.Issue(rec, "ABCD-1234-WXYZ", "proj-1", "Jane Doe") + + cookies := rec.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("expected 1 cookie, got %d", len(cookies)) + } + c := cookies[0] + if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode { + t.Errorf("cookie missing security attributes: %+v", c) + } + + got, err := m.Verify(newReqWithCookie(c.Value)) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if got.AuthCode != "ABCD-1234-WXYZ" || got.ProjectID != "proj-1" || got.DisplayName != "Jane Doe" { + t.Errorf("round-trip mismatch: %+v", got) + } +} + +func TestVerifyRejectsTamperedSignature(t *testing.T) { + m := NewManager([]byte("test-secret-at-least-16-bytes"), time.Hour, false) + rec := httptest.NewRecorder() + m.Issue(rec, "code", "proj", "name") + token := rec.Result().Cookies()[0].Value + + // Flip the last character of the signature. + tampered := token[:len(token)-1] + flip(token[len(token)-1]) + if _, err := m.Verify(newReqWithCookie(tampered)); err != ErrInvalid { + t.Errorf("expected ErrInvalid, got %v", err) + } +} + +func TestVerifyRejectsForeignSecret(t *testing.T) { + issuer := NewManager([]byte("secret-one-at-least-16b"), time.Hour, false) + attacker := NewManager([]byte("secret-two-at-least-16b"), time.Hour, false) + rec := httptest.NewRecorder() + issuer.Issue(rec, "code", "proj", "name") + token := rec.Result().Cookies()[0].Value + + if _, err := attacker.Verify(newReqWithCookie(token)); err != ErrInvalid { + t.Errorf("expected ErrInvalid for foreign secret, got %v", err) + } +} + +func TestVerifyExpired(t *testing.T) { + m := NewManager([]byte("test-secret-at-least-16-bytes"), time.Hour, false) + base := time.Now() + m.now = func() time.Time { return base } + rec := httptest.NewRecorder() + m.Issue(rec, "code", "proj", "name") + token := rec.Result().Cookies()[0].Value + + m.now = func() time.Time { return base.Add(2 * time.Hour) } + if _, err := m.Verify(newReqWithCookie(token)); err != ErrExpired { + t.Errorf("expected ErrExpired, got %v", err) + } +} + +func TestVerifyNoCookie(t *testing.T) { + m := NewManager([]byte("test-secret-at-least-16-bytes"), time.Hour, false) + r := httptest.NewRequest(http.MethodGet, "/", nil) + if _, err := m.Verify(r); err != ErrNoCookie { + t.Errorf("expected ErrNoCookie, got %v", err) + } +} + +func flip(b byte) string { + if b == 'A' { + return "B" + } + return "A" +} diff --git a/backend/internal/store/migrate.go b/backend/internal/store/migrate.go new file mode 100644 index 0000000..a0f5fd6 --- /dev/null +++ b/backend/internal/store/migrate.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "fmt" + "io/fs" + "sort" + + "github.com/jasonross/idea-collect/backend/migrations" +) + +// Migrate applies any embedded SQL migrations that have not yet been recorded in +// the schema_migrations table. Each migration runs in its own transaction. +func (s *Store) Migrate(ctx context.Context) error { + _, err := s.pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )`) + if err != nil { + return fmt.Errorf("creating schema_migrations: %w", err) + } + + entries, err := fs.Glob(migrations.FS, "*.sql") + if err != nil { + return err + } + sort.Strings(entries) + + for _, name := range entries { + var exists bool + if err := s.pool.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, name, + ).Scan(&exists); err != nil { + return fmt.Errorf("checking migration %s: %w", name, err) + } + if exists { + continue + } + + sqlBytes, err := migrations.FS.ReadFile(name) + if err != nil { + return fmt.Errorf("reading migration %s: %w", name, err) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("applying migration %s: %w", name, err) + } + if _, err := tx.Exec(ctx, + `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("recording migration %s: %w", name, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing migration %s: %w", name, err) + } + } + return nil +} diff --git a/backend/internal/store/models.go b/backend/internal/store/models.go new file mode 100644 index 0000000..5bc4c0a --- /dev/null +++ b/backend/internal/store/models.go @@ -0,0 +1,42 @@ +package store + +import "time" + +type Project struct { + ID string + Slug string + Name string + GitHubOwner string + GitHubRepo string + GitHubProjectID string + CreatedAt time.Time +} + +type User struct { + AuthCode string + ProjectID string + DisplayName string + Email *string + RevokedAt *time.Time + CreatedAt time.Time +} + +type Submission struct { + ID string + AuthCode string + ProjectID string + Title string + Body string + Status string + GitHubIssueNumber *int + GitHubIssueURL *string + GitHubNodeID *string + Error *string + CreatedAt time.Time +} + +const ( + SubmissionPending = "pending" + SubmissionCreated = "created" + SubmissionFailed = "failed" +) diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..5794944 --- /dev/null +++ b/backend/internal/store/store.go @@ -0,0 +1,231 @@ +// Package store provides PostgreSQL-backed persistence using pgx. uuid columns are +// cast to text in queries so they scan cleanly into Go strings. +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotFound is returned when a lookup matches no row. +var ErrNotFound = errors.New("not found") + +type Store struct { + pool *pgxpool.Pool +} + +// New opens a connection pool to the given database URL. +func New(ctx context.Context, databaseURL string) (*Store, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("connecting to database: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + return &Store{pool: pool}, nil +} + +func (s *Store) Close() { s.pool.Close() } + +// ---- projects ---- + +const projectCols = `id::text, slug, name, github_owner, github_repo, github_project_id, created_at` + +func scanProject(row pgx.Row) (Project, error) { + var p Project + err := row.Scan(&p.ID, &p.Slug, &p.Name, &p.GitHubOwner, &p.GitHubRepo, &p.GitHubProjectID, &p.CreatedAt) + return p, err +} + +func (s *Store) CreateProject(ctx context.Context, slug, name, owner, repo, projectNodeID string) (Project, error) { + row := s.pool.QueryRow(ctx, ` + INSERT INTO projects (slug, name, github_owner, github_repo, github_project_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING `+projectCols, + slug, name, owner, repo, projectNodeID) + return scanProject(row) +} + +func (s *Store) GetProjectBySlug(ctx context.Context, slug string) (Project, error) { + row := s.pool.QueryRow(ctx, `SELECT `+projectCols+` FROM projects WHERE slug = $1`, slug) + p, err := scanProject(row) + if errors.Is(err, pgx.ErrNoRows) { + return Project{}, ErrNotFound + } + return p, err +} + +func (s *Store) GetProjectByID(ctx context.Context, id string) (Project, error) { + row := s.pool.QueryRow(ctx, `SELECT `+projectCols+` FROM projects WHERE id = $1`, id) + p, err := scanProject(row) + if errors.Is(err, pgx.ErrNoRows) { + return Project{}, ErrNotFound + } + return p, err +} + +func (s *Store) ListProjects(ctx context.Context) ([]Project, error) { + rows, err := s.pool.Query(ctx, `SELECT `+projectCols+` FROM projects ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Project + for rows.Next() { + p, err := scanProject(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ---- users ---- + +const userCols = `auth_code, project_id::text, display_name, email, revoked_at, created_at` + +func scanUser(row pgx.Row) (User, error) { + var u User + err := row.Scan(&u.AuthCode, &u.ProjectID, &u.DisplayName, &u.Email, &u.RevokedAt, &u.CreatedAt) + return u, err +} + +func (s *Store) CreateUser(ctx context.Context, authCode, projectID, displayName string, email *string) (User, error) { + row := s.pool.QueryRow(ctx, ` + INSERT INTO users (auth_code, project_id, display_name, email) + VALUES ($1, $2, $3, $4) + RETURNING `+userCols, + authCode, projectID, displayName, email) + return scanUser(row) +} + +// GetActiveUser looks up a non-revoked user by auth_code and returns the user with +// their project. Returns ErrNotFound for unknown or revoked codes. +func (s *Store) GetActiveUser(ctx context.Context, authCode string) (User, Project, error) { + row := s.pool.QueryRow(ctx, ` + SELECT u.auth_code, u.project_id::text, u.display_name, u.email, u.revoked_at, u.created_at, + p.id::text, p.slug, p.name, p.github_owner, p.github_repo, p.github_project_id, p.created_at + FROM users u JOIN projects p ON p.id = u.project_id + WHERE u.auth_code = $1 AND u.revoked_at IS NULL`, authCode) + + var u User + var p Project + err := row.Scan( + &u.AuthCode, &u.ProjectID, &u.DisplayName, &u.Email, &u.RevokedAt, &u.CreatedAt, + &p.ID, &p.Slug, &p.Name, &p.GitHubOwner, &p.GitHubRepo, &p.GitHubProjectID, &p.CreatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return User{}, Project{}, ErrNotFound + } + if err != nil { + return User{}, Project{}, err + } + return u, p, nil +} + +// GetUserByCode returns a user regardless of revocation status (used for retries). +func (s *Store) GetUserByCode(ctx context.Context, authCode string) (User, error) { + row := s.pool.QueryRow(ctx, `SELECT `+userCols+` FROM users WHERE auth_code = $1`, authCode) + u, err := scanUser(row) + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrNotFound + } + return u, err +} + +func (s *Store) RevokeUser(ctx context.Context, authCode string) error { + tag, err := s.pool.Exec(ctx, + `UPDATE users SET revoked_at = now() WHERE auth_code = $1 AND revoked_at IS NULL`, authCode) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) ListUsers(ctx context.Context, projectID string) ([]User, error) { + rows, err := s.pool.Query(ctx, + `SELECT `+userCols+` FROM users WHERE project_id = $1 ORDER BY created_at`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// ---- submissions ---- + +const submissionCols = `id::text, auth_code, project_id::text, title, body, status, + github_issue_number, github_issue_url, github_node_id, error, created_at` + +func scanSubmission(row pgx.Row) (Submission, error) { + var sub Submission + err := row.Scan(&sub.ID, &sub.AuthCode, &sub.ProjectID, &sub.Title, &sub.Body, &sub.Status, + &sub.GitHubIssueNumber, &sub.GitHubIssueURL, &sub.GitHubNodeID, &sub.Error, &sub.CreatedAt) + return sub, err +} + +// CreateSubmission inserts a pending submission and returns its id. +func (s *Store) CreateSubmission(ctx context.Context, authCode, projectID, title, body string) (string, error) { + var id string + err := s.pool.QueryRow(ctx, ` + INSERT INTO submissions (auth_code, project_id, title, body, status) + VALUES ($1, $2, $3, $4, 'pending') + RETURNING id::text`, + authCode, projectID, title, body).Scan(&id) + return id, err +} + +func (s *Store) MarkSubmissionCreated(ctx context.Context, id string, issueNumber int, issueURL, nodeID string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE submissions + SET status = 'created', github_issue_number = $2, github_issue_url = $3, + github_node_id = $4, error = NULL + WHERE id = $1`, + id, issueNumber, issueURL, nodeID) + return err +} + +func (s *Store) MarkSubmissionFailed(ctx context.Context, id, errMsg string) error { + _, err := s.pool.Exec(ctx, + `UPDATE submissions SET status = 'failed', error = $2 WHERE id = $1`, id, errMsg) + return err +} + +// ListUnpublished returns submissions still pending or failed, oldest first, for retry. +func (s *Store) ListUnpublished(ctx context.Context, limit int) ([]Submission, error) { + rows, err := s.pool.Query(ctx, ` + SELECT `+submissionCols+` + FROM submissions WHERE status <> 'created' + ORDER BY created_at LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Submission + for rows.Next() { + sub, err := scanSubmission(rows) + if err != nil { + return nil, err + } + out = append(out, sub) + } + return out, rows.Err() +} diff --git a/backend/internal/store/store_integration_test.go b/backend/internal/store/store_integration_test.go new file mode 100644 index 0000000..859cdd3 --- /dev/null +++ b/backend/internal/store/store_integration_test.go @@ -0,0 +1,92 @@ +package store_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jasonross/idea-collect/backend/internal/authcode" + "github.com/jasonross/idea-collect/backend/internal/store" +) + +// Runs only when TEST_DATABASE_URL points at a reachable Postgres, e.g.: +// +// TEST_DATABASE_URL=postgres://idea:idea@localhost:5432/idea?sslmode=disable go test ./internal/store -run Integration +func TestIntegrationLifecycle(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("set TEST_DATABASE_URL to run store integration tests") + } + + ctx := context.Background() + st, err := store.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer st.Close() + if err := st.Migrate(ctx); err != nil { + t.Fatalf("migrate: %v", err) + } + + slug := "it-" + authcode.New() + project, err := st.CreateProject(ctx, slug, "Integration", "octo", "repo", "PVT_test") + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + + code := authcode.New() + email := "jane@example.com" + if _, err := st.CreateUser(ctx, code, project.ID, "Jane Doe", &email); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + user, gotProject, err := st.GetActiveUser(ctx, code) + if err != nil { + t.Fatalf("GetActiveUser: %v", err) + } + if user.DisplayName != "Jane Doe" || gotProject.ID != project.ID { + t.Fatalf("unexpected user/project: %+v / %+v", user, gotProject) + } + + subID, err := st.CreateSubmission(ctx, code, project.ID, "My idea", "details") + if err != nil { + t.Fatalf("CreateSubmission: %v", err) + } + + pending, err := st.ListUnpublished(ctx, 100) + if err != nil { + t.Fatalf("ListUnpublished: %v", err) + } + if !containsSubmission(pending, subID) { + t.Fatalf("new submission %s should be unpublished", subID) + } + + if err := st.MarkSubmissionCreated(ctx, subID, 7, "https://example/7", "I_node"); err != nil { + t.Fatalf("MarkSubmissionCreated: %v", err) + } + after, _ := st.ListUnpublished(ctx, 100) + if containsSubmission(after, subID) { + t.Fatalf("created submission %s should no longer be unpublished", subID) + } + + // Revoked users cannot authenticate but are still retrievable for retries. + if err := st.RevokeUser(ctx, code); err != nil { + t.Fatalf("RevokeUser: %v", err) + } + if _, _, err := st.GetActiveUser(ctx, code); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("revoked user should be ErrNotFound, got %v", err) + } + if _, err := st.GetUserByCode(ctx, code); err != nil { + t.Fatalf("GetUserByCode (revoked): %v", err) + } +} + +func containsSubmission(subs []store.Submission, id string) bool { + for _, s := range subs { + if s.ID == id { + return true + } + } + return false +} diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql new file mode 100644 index 0000000..6bdb463 --- /dev/null +++ b/backend/migrations/0001_init.sql @@ -0,0 +1,38 @@ +-- 0001_init: projects, users (auth_code is PK + login credential), submissions (outbox). +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug text UNIQUE NOT NULL, + name text NOT NULL, + github_owner text NOT NULL, + github_repo text NOT NULL, + github_project_id text NOT NULL, -- ProjectV2 node id (PVT_...) + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE users ( + auth_code text PRIMARY KEY, -- login credential (global PK) + project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + display_name text NOT NULL, -- goes into the issue body + email text, + revoked_at timestamptz, -- soft disable + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX users_project_id_idx ON users (project_id); + +CREATE TABLE submissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + auth_code text NOT NULL REFERENCES users(auth_code), + project_id uuid NOT NULL REFERENCES projects(id), + title text NOT NULL, + body text NOT NULL, + status text NOT NULL DEFAULT 'pending', -- pending|created|failed + github_issue_number integer, + github_issue_url text, + github_node_id text, + error text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX submissions_project_created_idx ON submissions (project_id, created_at); +CREATE INDEX submissions_status_idx ON submissions (status) WHERE status <> 'created'; diff --git a/backend/migrations/embed.go b/backend/migrations/embed.go new file mode 100644 index 0000000..cd4bdd8 --- /dev/null +++ b/backend/migrations/embed.go @@ -0,0 +1,8 @@ +// Package migrations embeds the SQL migration files so the server can apply them +// on startup without an external migration tool. +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/compose.dev.yml b/compose.dev.yml new file mode 100644 index 0000000..54d089d --- /dev/null +++ b/compose.dev.yml @@ -0,0 +1,20 @@ +# Local development only. Production uses Podman quadlets in deploy/quadlets/. +services: + db: + image: postgres:18 + environment: + POSTGRES_USER: idea + POSTGRES_PASSWORD: idea + POSTGRES_DB: idea + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U idea -d idea"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + pgdata: diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..29eb14f --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,63 @@ +# Caddy config. Environment variables are supplied via the quadlet EnvironmentFile +# (deploy/quadlets/caddy.container -> secrets/caddy.env): +# DOMAIN, ACME_EMAIL, GCP_PROJECT, BACKEND_UPSTREAM, GOOGLE_APPLICATION_CREDENTIALS + +{ + order coraza_waf first + email {$ACME_EMAIL} +} + +{$DOMAIN} { + # TLS via ACME DNS-01 using Google Cloud DNS (supports wildcards, no inbound :80 needed). + tls { + dns googleclouddns { + gcp_project {$GCP_PROJECT} + } + resolvers 8.8.8.8 1.1.1.1 + } + + log { + output file /var/log/caddy/access.log { + roll_size 20MiB + roll_keep 10 + } + format json + } + + encode zstd gzip + + # Web Application Firewall (OWASP CRS). Runs first for every request. + coraza_waf { + directives ` + Include /etc/caddy/coraza/coraza.conf + Include /etc/caddy/coraza/coreruleset/crs-setup.conf + Include /etc/caddy/coraza/coreruleset/rules/*.conf + SecRuleEngine On + ` + } + + # API + health proxied to the Go backend (Caddy active-health-checks the upstream). + @api path /api/* /healthz + handle @api { + reverse_proxy {$BACKEND_UPSTREAM} { + health_uri /healthz + health_interval 15s + health_timeout 5s + } + } + + # Static SPA; unknown routes fall back to index.html. + handle { + root * /srv/www + try_files {path} /index.html + file_server + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy strict-origin-when-cross-origin + -Server + } +} diff --git a/deploy/caddy/Containerfile b/deploy/caddy/Containerfile new file mode 100644 index 0000000..8a1816b --- /dev/null +++ b/deploy/caddy/Containerfile @@ -0,0 +1,29 @@ +# Custom Caddy with the Coraza WAF and Google Cloud DNS ACME plugins, bundled with +# the OWASP Core Rule Set. +# +# Build context is deploy/caddy/: +# podman build -t localhost/idea-collect-caddy:latest deploy/caddy +# +# NOTE: pin the plugin + CRS versions you validate. The Coraza directive syntax and +# CRS include paths should be confirmed against these versions at build time. +FROM caddy:2.9-builder AS builder +RUN xcaddy build \ + --with github.com/corazawaf/coraza-caddy/v2 \ + --with github.com/caddy-dns/googleclouddns + +FROM caddy:2.9 + +# OWASP Core Rule Set. +ARG CRS_VERSION=v4.10.0 +RUN apk add --no-cache git \ + && git clone --depth 1 --branch ${CRS_VERSION} \ + https://github.com/coreruleset/coreruleset /etc/caddy/coraza/coreruleset \ + && cp /etc/caddy/coraza/coreruleset/crs-setup.conf.example \ + /etc/caddy/coraza/coreruleset/crs-setup.conf \ + && apk del git + +COPY coraza/coraza.conf /etc/caddy/coraza/coraza.conf +COPY --from=builder /usr/bin/caddy /usr/bin/caddy + +# The Caddyfile, static site, GCP credentials and logs are bind-mounted at runtime +# (see deploy/quadlets/caddy.container). diff --git a/deploy/caddy/coraza/coraza.conf b/deploy/caddy/coraza/coraza.conf new file mode 100644 index 0000000..0822423 --- /dev/null +++ b/deploy/caddy/coraza/coraza.conf @@ -0,0 +1,29 @@ +# Coraza baseline (adapted from the upstream coraza.conf-recommended). Loaded before +# the OWASP CRS. Confirm against the coraza-caddy version you build with. + +# Inspect and (selectively) deny requests. +SecRuleEngine On + +# Request body handling. +SecRequestBodyAccess On +SecRequestBodyLimit 13107200 +SecRequestBodyNoFilesLimit 131072 +SecRequestBodyLimitAction Reject + +# Response body handling. +SecResponseBodyAccess On +SecResponseBodyMimeType text/plain text/html text/xml application/json +SecResponseBodyLimit 1048576 +SecResponseBodyLimitAction ProcessPartial + +# Audit logging — RelevantOnly keeps the noise down; fail2ban reads Caddy's access log, +# this audit log is for investigation. +SecAuditEngine RelevantOnly +SecAuditLogParts ABIJDEFHZ +SecAuditLog /var/log/caddy/coraza-audit.log +SecAuditLogType Serial + +# Miscellaneous. +SecArgumentSeparator & +SecCookieFormat 0 +SecStatusEngine Off diff --git a/deploy/quadlets/README.md b/deploy/quadlets/README.md new file mode 100644 index 0000000..87180bc --- /dev/null +++ b/deploy/quadlets/README.md @@ -0,0 +1,44 @@ +# Podman quadlets (rootless) + +These units run as the unprivileged `deploy` user. The Ansible `deploy` role copies +them to `~/.config/containers/systemd/` and runs `systemctl --user daemon-reload`. + +## Runtime layout (on the host, under the deploy user's home) + +``` +~/idea-collect/ +├── Caddyfile # bind-mounted into caddy +├── www/ # built frontend (dist) bind-mounted into caddy +├── logs/ # Caddy access + Coraza audit logs (fail2ban reads these) +└── secrets/ + ├── postgres.env + ├── backend.env + ├── caddy.env + ├── github-app.private-key.pem + └── gcp-dns-sa.json +``` + +## Prerequisites (handled by the Ansible `podman` role) + +- Rootless Podman installed; `loginctl enable-linger deploy` so units start at boot. +- `sysctl net.ipv4.ip_unprivileged_port_start=80` so rootless Caddy can bind 80/443. +- Images built/loaded: `localhost/idea-collect-backend:latest`, + `localhost/idea-collect-caddy:latest`. + +## Manual control (as the deploy user) + +```bash +systemctl --user daemon-reload +systemctl --user start caddy.service # pulls in backend -> postgres via Requires= +systemctl --user status caddy backend postgres +journalctl --user -u backend -f +``` + +## First-run migrations + +The backend applies embedded migrations automatically on startup. To provision projects +and invite codes, run the admin CLI inside the backend container: + +```bash +podman exec -it backend /usr/local/bin/admin project list +``` diff --git a/deploy/quadlets/backend.container b/deploy/quadlets/backend.container new file mode 100644 index 0000000..09bdccd --- /dev/null +++ b/deploy/quadlets/backend.container @@ -0,0 +1,21 @@ +[Unit] +Description=idea-collect backend API +Requires=postgres.service +After=postgres.service + +[Container] +Image=localhost/idea-collect-backend:latest +ContainerName=backend +Network=idea-collect.network +EnvironmentFile=%h/idea-collect/secrets/backend.env +# GitHub App private key, mounted read-only. +Volume=%h/idea-collect/secrets/github-app.private-key.pem:/etc/idea/github-app.pem:ro,Z +# Only reachable inside idea-collect.network; Caddy proxies to it. + +[Service] +Restart=always +# Postgres may still be starting on first boot; let the backend retry. +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/deploy/quadlets/caddy-config.volume b/deploy/quadlets/caddy-config.volume new file mode 100644 index 0000000..8d00ef2 --- /dev/null +++ b/deploy/quadlets/caddy-config.volume @@ -0,0 +1,8 @@ +[Unit] +Description=idea-collect Caddy config volume (autosaved config) + +[Volume] +VolumeName=caddy-config + +[Install] +WantedBy=default.target diff --git a/deploy/quadlets/caddy-data.volume b/deploy/quadlets/caddy-data.volume new file mode 100644 index 0000000..12b58b5 --- /dev/null +++ b/deploy/quadlets/caddy-data.volume @@ -0,0 +1,8 @@ +[Unit] +Description=idea-collect Caddy data volume (certificates, ACME state) + +[Volume] +VolumeName=caddy-data + +[Install] +WantedBy=default.target diff --git a/deploy/quadlets/caddy.container b/deploy/quadlets/caddy.container new file mode 100644 index 0000000..a65d18e --- /dev/null +++ b/deploy/quadlets/caddy.container @@ -0,0 +1,26 @@ +[Unit] +Description=idea-collect Caddy (TLS, WAF, reverse proxy) +Requires=backend.service +After=backend.service + +[Container] +Image=localhost/idea-collect-caddy:latest +ContainerName=caddy +Network=idea-collect.network +PublishPort=80:80 +PublishPort=443:443 +PublishPort=443:443/udp +Volume=caddy-data.volume:/data:Z +Volume=caddy-config.volume:/config:Z +Volume=%h/idea-collect/Caddyfile:/etc/caddy/Caddyfile:ro,Z +Volume=%h/idea-collect/www:/srv/www:ro,Z +Volume=%h/idea-collect/secrets/gcp-dns-sa.json:/etc/caddy/gcp-dns-sa.json:ro,Z +Volume=%h/idea-collect/logs:/var/log/caddy:Z +EnvironmentFile=%h/idea-collect/secrets/caddy.env + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/deploy/quadlets/idea-collect.network b/deploy/quadlets/idea-collect.network new file mode 100644 index 0000000..d89b02a --- /dev/null +++ b/deploy/quadlets/idea-collect.network @@ -0,0 +1,10 @@ +[Unit] +Description=idea-collect internal network + +[Network] +NetworkName=idea-collect +# Not Internal: backend needs to reach the GitHub API and Caddy needs ACME/DNS + the +# internet. Postgres and the backend are simply never published to the host. + +[Install] +WantedBy=default.target diff --git a/deploy/quadlets/idea-pgdata.volume b/deploy/quadlets/idea-pgdata.volume new file mode 100644 index 0000000..3ebf528 --- /dev/null +++ b/deploy/quadlets/idea-pgdata.volume @@ -0,0 +1,8 @@ +[Unit] +Description=idea-collect Postgres data volume + +[Volume] +VolumeName=idea-pgdata + +[Install] +WantedBy=default.target diff --git a/deploy/quadlets/postgres.container b/deploy/quadlets/postgres.container new file mode 100644 index 0000000..e2fdac6 --- /dev/null +++ b/deploy/quadlets/postgres.container @@ -0,0 +1,23 @@ +[Unit] +Description=idea-collect PostgreSQL +Wants=network-online.target +After=network-online.target + +[Container] +Image=docker.io/library/postgres:18 +ContainerName=postgres +Network=idea-collect.network +Volume=idea-pgdata.volume:/var/lib/postgresql/data:Z +EnvironmentFile=%h/idea-collect/secrets/postgres.env +# Only reachable inside idea-collect.network; no PublishPort. +HealthCmd=pg_isready -U idea -d idea +HealthInterval=10s +HealthRetries=5 +HealthStartPeriod=20s + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/deploy/secrets-examples/backend.env.example b/deploy/secrets-examples/backend.env.example new file mode 100644 index 0000000..6936715 --- /dev/null +++ b/deploy/secrets-examples/backend.env.example @@ -0,0 +1,17 @@ +# -> %h/idea-collect/secrets/backend.env (rendered by Ansible from vault) +DATABASE_URL=postgres://idea:change-me-strong@postgres:5432/idea?sslmode=disable +LISTEN_ADDR=:8080 + +# Served same-origin by Caddy in production, so the cookie is first-party and CORS is unused. +ALLOWED_ORIGINS= +COOKIE_SECURE=true +SESSION_TTL=2h +# openssl rand -base64 48 +SESSION_SECRET=replace-with-48-random-bytes-base64 + +AUTH_RATE_PER_MINUTE=10 +AUTH_RATE_BURST=5 + +GITHUB_APP_ID=123456 +GITHUB_APP_INSTALLATION_ID=12345678 +GITHUB_APP_PRIVATE_KEY_FILE=/etc/idea/github-app.pem diff --git a/deploy/secrets-examples/caddy.env.example b/deploy/secrets-examples/caddy.env.example new file mode 100644 index 0000000..586df6f --- /dev/null +++ b/deploy/secrets-examples/caddy.env.example @@ -0,0 +1,7 @@ +# -> %h/idea-collect/secrets/caddy.env (rendered by Ansible) +DOMAIN=ideas.example.com +ACME_EMAIL=ops@example.com +GCP_PROJECT=my-gcp-project +BACKEND_UPSTREAM=backend:8080 +# The googleclouddns ACME plugin reads service-account credentials from this path. +GOOGLE_APPLICATION_CREDENTIALS=/etc/caddy/gcp-dns-sa.json diff --git a/deploy/secrets-examples/postgres.env.example b/deploy/secrets-examples/postgres.env.example new file mode 100644 index 0000000..661be3e --- /dev/null +++ b/deploy/secrets-examples/postgres.env.example @@ -0,0 +1,5 @@ +# -> %h/idea-collect/secrets/postgres.env (rendered by Ansible from vault) +POSTGRES_USER=idea +POSTGRES_PASSWORD=change-me-strong +POSTGRES_DB=idea +PGDATA=/var/lib/postgresql/data diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f89260e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Share an idea + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a3a49bb --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1566 @@ +{ + "name": "idea-collect-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "idea-collect-frontend", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", + "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", + "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.11", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz", + "integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "0.1.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..35fb37e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "idea-collect-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..55eca81 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,138 @@ + + +
+
+ {#if booting} +

Loading…

+ {:else if !info} +

Share an idea

+

Enter the invite code you were given to get started.

+
+ + + {#if authError}

{authError}

{/if} + +
+ {:else if result} +

Thank you! 🎉

+

Your idea for {info.project_name} has been received.

+

It was logged as item #{result.issue_number}.

+ + + {:else} +
+ Hi {info.display_name} + +
+

{info.project_name}

+

What would you like to suggest?

+
+ + + + + {#if submitError}

{submitError}

{/if} + +
+ {/if} +
+
Your name is shared with the team along with your idea.
+
diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..dc2d0e3 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,56 @@ +// Typed client for the idea-collect backend. All calls are same-origin and send the +// session cookie (credentials: 'include'). + +export interface AuthInfo { + project_name: string; + display_name: string; +} + +export interface SubmitResult { + issue_url: string; + issue_number: number; +} + +async function handle(res: Response): Promise { + if (!res.ok) { + let message = `Something went wrong (${res.status}).`; + try { + const data = await res.json(); + if (data && typeof data.error === 'string') message = data.error; + } catch { + // non-JSON error body; keep the default message + } + throw new Error(message); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +async function postJSON(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return handle(res); +} + +export const api = { + /** Exchange an invite code for a session. */ + auth: (code: string) => postJSON('/api/auth', { auth_code: code }), + + /** Return the current session, or null if not signed in. */ + async session(): Promise { + const res = await fetch('/api/session', { credentials: 'include' }); + if (res.status === 401) return null; + return handle(res); + }, + + /** Submit a new idea. */ + submit: (title: string, body: string) => + postJSON('/api/submissions', { title, body }), + + /** End the session. */ + logout: () => postJSON('/api/logout', {}), +}; diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..0ba20df --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,140 @@ +:root { + --bg: #f4f5f7; + --card: #ffffff; + --text: #1c2024; + --muted: #6b7280; + --accent: #2f6feb; + --accent-hover: #2457c4; + --error: #c0392b; + --border: #d8dbe0; + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); +} + +main { + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 1.5rem; +} + +.card { + background: var(--card); + width: 100%; + max-width: 30rem; + padding: 2rem; + border-radius: 14px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(0, 0, 0, 0.06); +} + +h1 { + font-size: 1.5rem; + margin: 0 0 0.25rem; +} + +.muted { + color: var(--muted); + margin: 0.25rem 0; +} + +.error { + color: var(--error); + margin: 0.5rem 0 0; + font-size: 0.9rem; +} + +form { + margin-top: 1.25rem; + display: flex; + flex-direction: column; +} + +label { + font-weight: 600; + font-size: 0.9rem; + margin: 0.75rem 0 0.35rem; +} + +input, +textarea { + font: inherit; + padding: 0.65rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + color: var(--text); +} + +input:focus, +textarea:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-color: var(--accent); +} + +textarea { + resize: vertical; +} + +button { + font: inherit; + margin-top: 1.25rem; + padding: 0.7rem 1rem; + border: none; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; +} + +button:hover:not(:disabled) { + background: var(--accent-hover); +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +button.link { + background: none; + color: var(--muted); + margin-top: 0.5rem; + padding: 0.25rem; + font-weight: 500; + text-decoration: underline; +} + +button.link:hover:not(:disabled) { + background: none; + color: var(--text); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.topbar button.link { + margin: 0; +} + +footer { + font-size: 0.85rem; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..c049299 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte'; +import './app.css'; +import App from './App.svelte'; + +const app = mount(App, { + target: document.getElementById('app')!, +}); + +export default app; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..4c6b24b --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..300a2c1 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ad60548 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +// In dev, proxy API calls to the Go backend so the SPA and API share an origin +// (keeps the session cookie first-party). +export default defineConfig({ + plugins: [svelte()], + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:8080', + '/healthz': 'http://localhost:8080', + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + }, +}); diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..146f5a2 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,114 @@ +# Deployment runbook + +End-to-end: **Pulumi** provisions the server + DNS, then **Ansible** hardens the host and +deploys the app as rootless Podman quadlets. + +## 0. Prerequisites + +- A domain delegated to **Google Cloud DNS**; a GCP project with the Cloud DNS API enabled. +- A **Hetzner Cloud** API token. +- A **GitHub App** (App ID, installation ID, private key) with *Issues: write* and + *Projects: write*, installed on the org/repos that host your boards. +- A **Cloudflare R2** bucket + access key/secret (Pulumi state). +- Tooling on your workstation: `pulumi`, `go`, `ansible`, `rsync`, `node`/`npm`. + +## 1. Configure the Pulumi state backend (Cloudflare R2) + +```bash +export AWS_ACCESS_KEY_ID= +export AWS_SECRET_ACCESS_KEY= +export PULUMI_CONFIG_PASSPHRASE= +pulumi login "s3://idea-collect-pulumi-state?endpoint=.r2.cloudflarestorage.com®ion=auto&s3ForcePathStyle=true" +``` + +## 2. Provision infrastructure + +```bash +cd infra/pulumi +pulumi stack init prod +pulumi config set --secret hcloud:token +# Edit Pulumi.prod.yaml (domain, sshPublicKey, dnsZoneName, gcp:project, sshSourceIps…) +pulumi up +``` + +Outputs: `serverIPv4/serverIPv6`, `domain`, and `gcpDnsSaKeyBase64` (secret — the Caddy +DNS service-account key). + +## 3. Prepare Ansible vars + secrets + +```bash +cd ../ansible +ansible-galaxy collection install -r requirements.yml +cp group_vars/all.example.yml group_vars/all.yml # edit +cp group_vars/vault.example.yml group_vars/vault.yml # edit, then encrypt + +# The GCP DNS service-account JSON comes from Pulumi: +pulumi -C ../pulumi stack output gcpDnsSaKeyBase64 --show-secrets --stack prod | base64 -d +# paste into vault.yml as vault_gcp_dns_sa_json, then: +ansible-vault encrypt group_vars/vault.yml +``` + +## 4. Build the frontend and generate the inventory + +```bash +make -C ../../ frontend-build # produces frontend/dist (synced to the host as the SPA) +./scripts/inventory.sh prod # writes inventory/hosts.generated.ini from Pulumi +``` + +## 5. Configure + deploy the host + +```bash +ansible-playbook site.yml --ask-vault-pass +``` + +This runs the roles in order: **base** (hardening, deploy user, unattended upgrades) → +**nftables** (default-drop firewall) → **podman** (rootless + linger + port sysctl) → +**fail2ban** (SSH + Caddy/Coraza jails) → **secrets** (env/keys from vault) → **deploy** +(build images, install quadlets, start services). The backend applies DB migrations on +startup. + +## 6. Provision projects + invite codes + +```bash +ssh deploy@ +podman exec -it backend /usr/local/bin/admin \ + project create --slug demo --name "Demo App" --owner my-org --repo demo-feedback --project-number 1 +podman exec -it backend /usr/local/bin/admin \ + user create --project demo --name "Jane Doe" --email jane@example.com +# -> prints the invite code to share +``` + +## Verify + +```bash +curl -I https:// # valid cert via ACME DNS-01 (Google Cloud DNS) +# Submit an idea through the form, then confirm the GitHub issue + board item appear. + +# WAF: a SQLi-looking probe should be blocked by Coraza (403) +curl -i "https:///api/?id=1%27%20OR%20%271%27=%271" + +# fail2ban: after several bad codes the client IP is banned +sudo fail2ban-client status caddy-auth +sudo nft list ruleset | grep -A3 'set f2b' +``` + +## Operations + +```bash +# As the deploy user: +systemctl --user status caddy backend postgres +journalctl --user -u backend -f +# Retry any submissions whose GitHub call failed: +podman exec -it backend /usr/local/bin/admin submission retry +``` + +## Architecture notes + +- Only Caddy publishes `80/443`; backend and Postgres are reachable only on the internal + Podman network. Rootless Caddy binds the privileged ports via + `net.ipv4.ip_unprivileged_port_start=80`. +- Two firewall layers: the Hetzner Cloud firewall (restricts SSH/dev-port *source* to + `sshSourceIps`) and host nftables (default-drop). In the `dev` environment both also open + `5173/8080/5432`. +- TLS uses ACME **DNS-01** via Google Cloud DNS, so certificates issue without any inbound + dependency and support wildcards. diff --git a/infra/ansible/ansible.cfg b/infra/ansible/ansible.cfg new file mode 100644 index 0000000..0d4d2a8 --- /dev/null +++ b/infra/ansible/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = inventory/hosts.generated.ini +roles_path = roles +host_key_checking = False +remote_user = root +retry_files_enabled = False +stdout_callback = yaml + +[privilege_escalation] +become = True +become_method = sudo diff --git a/infra/ansible/group_vars/all.example.yml b/infra/ansible/group_vars/all.example.yml new file mode 100644 index 0000000..12f8706 --- /dev/null +++ b/infra/ansible/group_vars/all.example.yml @@ -0,0 +1,33 @@ +--- +# Copy to group_vars/all.yml and adjust. Secrets live in group_vars/vault.yml (see +# vault.example.yml) and are encrypted with ansible-vault. + +environment_name: prod # prod | dev (dev also opens 5173/8080/5432) + +deploy_user: deploy +app_dir: "/home/{{ deploy_user }}/idea-collect" + +# Public SSH keys granted to root and the deploy user. +admin_ssh_public_keys: + - "ssh-ed25519 AAAAC3Nza... you@workstation" + +ssh_port: 22 + +# Application / Caddy settings. +domain: ideas.example.com +acme_email: ops@example.com +gcp_project: my-gcp-project + +# GitHub App identifiers (the private key is in vault). +github_app_id: 123456 +github_app_installation_id: 12345678 + +# Tuning. +auth_rate_per_minute: 10 +auth_rate_burst: 5 +session_ttl: 2h + +# fail2ban. +fail2ban_bantime: 1h +fail2ban_findtime: 10m +fail2ban_maxretry: 5 diff --git a/infra/ansible/group_vars/vault.example.yml b/infra/ansible/group_vars/vault.example.yml new file mode 100644 index 0000000..6958712 --- /dev/null +++ b/infra/ansible/group_vars/vault.example.yml @@ -0,0 +1,21 @@ +--- +# Copy to group_vars/vault.yml and encrypt: +# ansible-vault encrypt group_vars/vault.yml +# +# Obtain the GCP DNS service-account JSON from the Pulumi output: +# pulumi stack output gcpDnsSaKeyBase64 --show-secrets --stack prod | base64 -d + +vault_postgres_password: "change-me-strong" +vault_session_secret: "replace-with-48-random-bytes-base64" # openssl rand -base64 48 + +vault_github_app_private_key: | + -----BEGIN RSA PRIVATE KEY----- + ...your GitHub App private key... + -----END RSA PRIVATE KEY----- + +vault_gcp_dns_sa_json: | + { + "type": "service_account", + "project_id": "my-gcp-project", + "...": "..." + } diff --git a/infra/ansible/inventory/hosts.example.ini b/infra/ansible/inventory/hosts.example.ini new file mode 100644 index 0000000..3f0208c --- /dev/null +++ b/infra/ansible/inventory/hosts.example.ini @@ -0,0 +1,4 @@ +# Generated by scripts/inventory.sh from Pulumi outputs into hosts.generated.ini. +# This example shows the expected shape. +[idea_collect] +203.0.113.10 ansible_user=root diff --git a/infra/ansible/requirements.yml b/infra/ansible/requirements.yml new file mode 100644 index 0000000..654f52a --- /dev/null +++ b/infra/ansible/requirements.yml @@ -0,0 +1,5 @@ +--- +# ansible-galaxy collection install -r requirements.yml +collections: + - name: ansible.posix # sysctl, authorized_key, synchronize + - name: community.general # timezone diff --git a/infra/ansible/roles/base/handlers/main.yml b/infra/ansible/roles/base/handlers/main.yml new file mode 100644 index 0000000..6665708 --- /dev/null +++ b/infra/ansible/roles/base/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart ssh + ansible.builtin.service: + name: ssh + state: restarted diff --git a/infra/ansible/roles/base/tasks/main.yml b/infra/ansible/roles/base/tasks/main.yml new file mode 100644 index 0000000..27a7ee3 --- /dev/null +++ b/infra/ansible/roles/base/tasks/main.yml @@ -0,0 +1,58 @@ +--- +- name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + +- name: Upgrade installed packages + ansible.builtin.apt: + upgrade: dist + +- name: Install base packages + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + - python3 + - acl + - rsync + - chrony + - unattended-upgrades + state: present + +- name: Enable unattended security upgrades + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + owner: root + group: root + mode: "0644" + +- name: Set timezone to UTC + community.general.timezone: + name: Etc/UTC + +- name: Create deploy user + ansible.builtin.user: + name: "{{ deploy_user }}" + shell: /bin/bash + create_home: true + +- name: Authorize SSH keys for root and deploy + ansible.posix.authorized_key: + user: "{{ item.0 }}" + key: "{{ item.1 }}" + state: present + loop: "{{ ['root', deploy_user] | product(admin_ssh_public_keys) | list }}" + +- name: Harden sshd + ansible.builtin.template: + src: 99-hardening.conf.j2 + dest: /etc/ssh/sshd_config.d/99-hardening.conf + owner: root + group: root + mode: "0644" + notify: Restart ssh diff --git a/infra/ansible/roles/base/templates/99-hardening.conf.j2 b/infra/ansible/roles/base/templates/99-hardening.conf.j2 new file mode 100644 index 0000000..59f6110 --- /dev/null +++ b/infra/ansible/roles/base/templates/99-hardening.conf.j2 @@ -0,0 +1,9 @@ +# Managed by Ansible (base role) +Port {{ ssh_port }} +PermitRootLogin prohibit-password +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +X11Forwarding no +MaxAuthTries 3 +AllowTcpForwarding no diff --git a/infra/ansible/roles/deploy/tasks/main.yml b/infra/ansible/roles/deploy/tasks/main.yml new file mode 100644 index 0000000..695654b --- /dev/null +++ b/infra/ansible/roles/deploy/tasks/main.yml @@ -0,0 +1,105 @@ +--- +- name: Resolve deploy user uid + ansible.builtin.command: "id -u {{ deploy_user }}" + register: deploy_uid_result + changed_when: false + +- name: Set user systemd environment + ansible.builtin.set_fact: + user_env: + XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid_result.stdout }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ deploy_uid_result.stdout }}/bus" + +- name: Ensure runtime directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: "0755" + loop: + - "{{ app_dir }}/www" + - "{{ app_dir }}/logs" + - "{{ app_dir }}/build" + +- name: Sync backend build context + ansible.posix.synchronize: + src: "{{ playbook_dir }}/../../backend/" + dest: "{{ app_dir }}/build/backend/" + delete: true + rsync_opts: + - "--exclude=bin" + - "--exclude=.git" + become: false + +- name: Sync caddy build context + ansible.posix.synchronize: + src: "{{ playbook_dir }}/../../deploy/caddy/" + dest: "{{ app_dir }}/build/caddy/" + delete: true + become: false + +- name: Sync built frontend (run `make frontend-build` first) + ansible.posix.synchronize: + src: "{{ playbook_dir }}/../../frontend/dist/" + dest: "{{ app_dir }}/www/" + delete: true + become: false + +- name: Build backend image + ansible.builtin.command: + cmd: podman build -t localhost/idea-collect-backend:latest -f Containerfile . + chdir: "{{ app_dir }}/build/backend" + become: true + become_user: "{{ deploy_user }}" + environment: "{{ user_env }}" + +- name: Build Caddy image (Coraza + Google Cloud DNS) + ansible.builtin.command: + cmd: podman build -t localhost/idea-collect-caddy:latest . + chdir: "{{ app_dir }}/build/caddy" + become: true + become_user: "{{ deploy_user }}" + environment: "{{ user_env }}" + +- name: Install Caddyfile + ansible.builtin.copy: + remote_src: true + src: "{{ app_dir }}/build/caddy/Caddyfile" + dest: "{{ app_dir }}/Caddyfile" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: "0644" + +- name: Install quadlet units + ansible.builtin.copy: + src: "{{ playbook_dir }}/../../deploy/quadlets/{{ item }}" + dest: "/home/{{ deploy_user }}/.config/containers/systemd/{{ item }}" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: "0644" + loop: + - idea-collect.network + - idea-pgdata.volume + - caddy-data.volume + - caddy-config.volume + - postgres.container + - backend.container + - caddy.container + +- name: Reload user systemd (generate quadlet services) + ansible.builtin.systemd_service: + scope: user + daemon_reload: true + become: true + become_user: "{{ deploy_user }}" + environment: "{{ user_env }}" + +- name: Start the stack (caddy pulls in backend -> postgres) + ansible.builtin.systemd_service: + name: caddy.service + scope: user + state: restarted + become: true + become_user: "{{ deploy_user }}" + environment: "{{ user_env }}" diff --git a/infra/ansible/roles/fail2ban/files/caddy-auth.conf b/infra/ansible/roles/fail2ban/files/caddy-auth.conf new file mode 100644 index 0000000..ba66a37 --- /dev/null +++ b/infra/ansible/roles/fail2ban/files/caddy-auth.conf @@ -0,0 +1,5 @@ +# fail2ban filter: failed invite-code attempts (HTTP 401 on /api/auth) in Caddy's +# JSON access log. binds the real client IP that Caddy logs as client_ip. +[Definition] +failregex = ^.*"client_ip":"".*"uri":"/api/auth".*"status":401 +datepattern = {EPOCH} diff --git a/infra/ansible/roles/fail2ban/files/caddy-coraza.conf b/infra/ansible/roles/fail2ban/files/caddy-coraza.conf new file mode 100644 index 0000000..169a5bb --- /dev/null +++ b/infra/ansible/roles/fail2ban/files/caddy-coraza.conf @@ -0,0 +1,4 @@ +# fail2ban filter: Coraza WAF blocks (HTTP 403) in Caddy's JSON access log. +[Definition] +failregex = ^.*"client_ip":"".*"status":403 +datepattern = {EPOCH} diff --git a/infra/ansible/roles/fail2ban/handlers/main.yml b/infra/ansible/roles/fail2ban/handlers/main.yml new file mode 100644 index 0000000..e66a56d --- /dev/null +++ b/infra/ansible/roles/fail2ban/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart fail2ban + ansible.builtin.systemd_service: + name: fail2ban + state: restarted diff --git a/infra/ansible/roles/fail2ban/tasks/main.yml b/infra/ansible/roles/fail2ban/tasks/main.yml new file mode 100644 index 0000000..884947b --- /dev/null +++ b/infra/ansible/roles/fail2ban/tasks/main.yml @@ -0,0 +1,38 @@ +--- +- name: Install fail2ban + ansible.builtin.apt: + name: fail2ban + state: present + +- name: Deploy Caddy auth-failure filter + ansible.builtin.copy: + src: caddy-auth.conf + dest: /etc/fail2ban/filter.d/caddy-auth.conf + owner: root + group: root + mode: "0644" + notify: Restart fail2ban + +- name: Deploy Coraza WAF-block filter + ansible.builtin.copy: + src: caddy-coraza.conf + dest: /etc/fail2ban/filter.d/caddy-coraza.conf + owner: root + group: root + mode: "0644" + notify: Restart fail2ban + +- name: Deploy jail.local + ansible.builtin.template: + src: jail.local.j2 + dest: /etc/fail2ban/jail.local + owner: root + group: root + mode: "0644" + notify: Restart fail2ban + +- name: Enable and start fail2ban + ansible.builtin.systemd_service: + name: fail2ban + enabled: true + state: started diff --git a/infra/ansible/roles/fail2ban/templates/jail.local.j2 b/infra/ansible/roles/fail2ban/templates/jail.local.j2 new file mode 100644 index 0000000..80cce2b --- /dev/null +++ b/infra/ansible/roles/fail2ban/templates/jail.local.j2 @@ -0,0 +1,26 @@ +# Managed by Ansible (fail2ban role). Bans are applied via nftables. +[DEFAULT] +bantime = {{ fail2ban_bantime }} +findtime = {{ fail2ban_findtime }} +maxretry = {{ fail2ban_maxretry }} +banaction = nftables-multiport +banaction_allports = nftables-allports +backend = auto + +[sshd] +enabled = true +port = {{ ssh_port }} + +[caddy-auth] +enabled = true +filter = caddy-auth +port = http,https +logpath = {{ app_dir }}/logs/access.log +maxretry = {{ fail2ban_maxretry }} + +[caddy-coraza] +enabled = true +filter = caddy-coraza +port = http,https +logpath = {{ app_dir }}/logs/access.log +maxretry = 3 diff --git a/infra/ansible/roles/nftables/handlers/main.yml b/infra/ansible/roles/nftables/handlers/main.yml new file mode 100644 index 0000000..da95052 --- /dev/null +++ b/infra/ansible/roles/nftables/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload nftables + ansible.builtin.systemd_service: + name: nftables + state: reloaded diff --git a/infra/ansible/roles/nftables/tasks/main.yml b/infra/ansible/roles/nftables/tasks/main.yml new file mode 100644 index 0000000..c55e769 --- /dev/null +++ b/infra/ansible/roles/nftables/tasks/main.yml @@ -0,0 +1,26 @@ +--- +- name: Install nftables + ansible.builtin.apt: + name: nftables + state: present + +- name: Remove ufw (conflicts with nftables ruleset) + ansible.builtin.apt: + name: ufw + state: absent + +- name: Deploy nftables ruleset + ansible.builtin.template: + src: nftables.conf.j2 + dest: /etc/nftables.conf + owner: root + group: root + mode: "0644" + validate: "nft -c -f %s" + notify: Reload nftables + +- name: Enable and start nftables + ansible.builtin.systemd_service: + name: nftables + enabled: true + state: started diff --git a/infra/ansible/roles/nftables/templates/nftables.conf.j2 b/infra/ansible/roles/nftables/templates/nftables.conf.j2 new file mode 100644 index 0000000..18d02c5 --- /dev/null +++ b/infra/ansible/roles/nftables/templates/nftables.conf.j2 @@ -0,0 +1,37 @@ +#!/usr/sbin/nft -f +# Managed by Ansible (nftables role). Default-drop inbound. +# Source restriction for SSH and dev ports is enforced at the Hetzner Cloud firewall +# (Pulumi sshSourceIps); this host firewall is the defense-in-depth default-drop layer. +flush ruleset + +table inet filter { + chain input { + type filter hook input priority 0; policy drop; + + iif "lo" accept + ct state established,related accept + ct state invalid drop + + ip protocol icmp accept + ip6 nexthdr ipv6-icmp accept + + tcp dport {{ ssh_port }} accept + tcp dport { 80, 443 } accept + udp dport 443 accept +{% if environment_name == 'dev' %} + + # Development-only higher ports (Vite, backend, Postgres). + tcp dport { 5173, 8080, 5432 } accept +{% endif %} + } + + chain forward { + # Rootless Podman uses pasta/slirp in userspace; container egress is via the + # host's own routing. Keep forward permissive but the box default-drops input. + type filter hook forward priority 0; policy accept; + } + + chain output { + type filter hook output priority 0; policy accept; + } +} diff --git a/infra/ansible/roles/podman/tasks/main.yml b/infra/ansible/roles/podman/tasks/main.yml new file mode 100644 index 0000000..30c5fed --- /dev/null +++ b/infra/ansible/roles/podman/tasks/main.yml @@ -0,0 +1,31 @@ +--- +- name: Install Podman and rootless dependencies + ansible.builtin.apt: + name: + - podman + - slirp4netns + - fuse-overlayfs + - uidmap + - passt + state: present + +- name: Allow rootless binding of privileged ports (80/443) + ansible.posix.sysctl: + name: net.ipv4.ip_unprivileged_port_start + value: "80" + sysctl_set: true + state: present + reload: true + +- name: Enable lingering so the deploy user's services start at boot + ansible.builtin.command: + cmd: "loginctl enable-linger {{ deploy_user }}" + creates: "/var/lib/systemd/linger/{{ deploy_user }}" + +- name: Ensure the user quadlet directory exists + ansible.builtin.file: + path: "/home/{{ deploy_user }}/.config/containers/systemd" + state: directory + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: "0755" diff --git a/infra/ansible/scripts/inventory.sh b/infra/ansible/scripts/inventory.sh new file mode 100644 index 0000000..3e236f0 --- /dev/null +++ b/infra/ansible/scripts/inventory.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Generate the Ansible inventory from Pulumi stack outputs. +# ./scripts/inventory.sh [stack] (default: prod) +set -euo pipefail + +stack="${1:-prod}" +here="$(cd "$(dirname "$0")" && pwd)" +pulumi_dir="$here/../../pulumi" +out="$here/../inventory/hosts.generated.ini" + +ip=$(pulumi -C "$pulumi_dir" stack output serverIPv4 --stack "$stack") +user=$(pulumi -C "$pulumi_dir" stack output sshUser --stack "$stack") + +mkdir -p "$(dirname "$out")" +cat > "$out" < +config: + gcp:project: my-gcp-project + idea-collect-infra:environment: dev + idea-collect-infra:domain: ideas-dev.example.com + idea-collect-infra:hcloudServerType: cax11 + idea-collect-infra:hcloudLocation: fsn1 + idea-collect-infra:hcloudImage: debian-12 + idea-collect-infra:dnsZoneName: example-com + idea-collect-infra:createDnsZone: "false" + idea-collect-infra:sshPublicKey: ssh-ed25519 AAAAC3Nza... you@workstation + idea-collect-infra:sshSourceIps: + - 203.0.113.0/24 diff --git a/infra/pulumi/Pulumi.prod.yaml b/infra/pulumi/Pulumi.prod.yaml new file mode 100644 index 0000000..a77067b --- /dev/null +++ b/infra/pulumi/Pulumi.prod.yaml @@ -0,0 +1,15 @@ +# Example prod stack config. Set secrets via the CLI (never commit them): +# pulumi config set --secret hcloud:token +# gcp auth uses GOOGLE_CREDENTIALS / application-default credentials. +config: + gcp:project: my-gcp-project + idea-collect-infra:environment: prod + idea-collect-infra:domain: ideas.example.com + idea-collect-infra:hcloudServerType: cax11 + idea-collect-infra:hcloudLocation: fsn1 + idea-collect-infra:hcloudImage: debian-12 + idea-collect-infra:dnsZoneName: example-com + idea-collect-infra:createDnsZone: "false" + idea-collect-infra:sshPublicKey: ssh-ed25519 AAAAC3Nza... you@workstation + idea-collect-infra:sshSourceIps: + - 203.0.113.0/24 diff --git a/infra/pulumi/Pulumi.yaml b/infra/pulumi/Pulumi.yaml new file mode 100644 index 0000000..0c43821 --- /dev/null +++ b/infra/pulumi/Pulumi.yaml @@ -0,0 +1,11 @@ +name: idea-collect-infra +runtime: go +description: Hetzner Cloud server + Google Cloud DNS for idea-collect. + +# State is stored in Cloudflare R2 (S3-compatible). Log in before selecting a stack: +# +# export AWS_ACCESS_KEY_ID= +# export AWS_SECRET_ACCESS_KEY= +# pulumi login "s3://idea-collect-pulumi-state?endpoint=.r2.cloudflarestorage.com®ion=auto&s3ForcePathStyle=true" +# +# Secrets provider is a passphrase (PULUMI_CONFIG_PASSPHRASE) since R2 only holds state. diff --git a/infra/pulumi/go.mod b/infra/pulumi/go.mod new file mode 100644 index 0000000..612e4a8 --- /dev/null +++ b/infra/pulumi/go.mod @@ -0,0 +1,120 @@ +module github.com/jasonross/idea-collect/infra/pulumi + +go 1.26 + +require ( + github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1 + github.com/pulumi/pulumi-hcloud/sdk v1.38.0 + github.com/pulumi/pulumi/sdk/v3 v3.247.0 +) + +require ( + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/cheggaaa/pb v1.0.29 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/djherbis/times v1.5.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-git/gcfg/v2 v2.0.2 // indirect + github.com/go-git/go-billy/v6 v6.0.0-alpha.1 // indirect + github.com/go-git/go-git/v6 v6.0.0-alpha.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v1.2.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/hcl/v2 v2.22.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/opentracing/basictracer-go v1.1.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pgavlin/fx v0.1.6 // indirect + github.com/pgavlin/fx/v2 v2.0.12 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/term v1.1.0 // indirect + github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect + github.com/pulumi/esc v0.24.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/spf13/cast v1.4.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/texttheater/golang-levenshtein v1.0.1 // indirect + github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect + github.com/uber/jaeger-lib v2.4.1+incompatible // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zclconf/go-cty v1.13.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/collector/featuregate v1.53.0 // indirect + go.opentelemetry.io/collector/pdata v1.53.0 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/frand v1.4.2 // indirect +) diff --git a/infra/pulumi/go.sum b/infra/pulumi/go.sum new file mode 100644 index 0000000..86f2b75 --- /dev/null +++ b/infra/pulumi/go.sum @@ -0,0 +1,344 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= +github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= +github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU= +github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= +github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1 h1:xVjAR4oUvrKy7/Xuw/lLlV3gkxR3KO2H8W+MamuVVsQ= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/opentracing/basictracer-go v1.1.0 h1:Oa1fTSBvAl8pa3U+IJYqrKm0NALwH9OsgwOqDv4xJW0= +github.com/opentracing/basictracer-go v1.1.0/go.mod h1:V2HZueSJEp879yv285Aap1BS69fQMD+MNP1mRs6mBQc= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pgavlin/fx v0.1.6 h1:r9jEg69DhNoCd3Xh0+5mIbdbS3PqWrVWujkY76MFRTU= +github.com/pgavlin/fx v0.1.6/go.mod h1:KWZJ6fqBBSh8GxHYqwYCf3rYE7Gp2p0N8tJp8xv9u9M= +github.com/pgavlin/fx/v2 v2.0.12 h1:SjjaJ68Dt8Z4zHwOpY/RPijd7lShs6xYupJbF9ra00M= +github.com/pgavlin/fx/v2 v2.0.12/go.mod h1:M/nF/ooAOy+NUBooYYXl2REARzJ/giPJxfMs8fINfKc= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk= +github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435cARxCW6q9gc0S/Yxz7Mkd38pOb0= +github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE= +github.com/pulumi/esc v0.24.0 h1:sCtiB0qbyrlU1ZNzJn4dTLYiChl8xeCBFbHWl1YoXJg= +github.com/pulumi/esc v0.24.0/go.mod h1:eCOOkcDJS6eooGwdE4/E0+pOsvUWG254+KBmPCFwJpA= +github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1 h1:w6OnO3d4j5yVf2vpm8OzXFC/xHOEGqt+9FjWCUBCq6U= +github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1/go.mod h1:UyZyv7hz4knpFx6/Sh+SkZe6hT6sJHtDvw9A0TbvEsk= +github.com/pulumi/pulumi-hcloud/sdk v1.38.0 h1:scBIJRJph/kzo++iycvP7caIzMujhOagn+T3Mk9W2eI= +github.com/pulumi/pulumi-hcloud/sdk v1.38.0/go.mod h1:rfOSimT+VT5KOiEO62R+wibR+Y3zDGHigOvzPecJ56g= +github.com/pulumi/pulumi/sdk/v3 v3.247.0 h1:AlQeVeyzc7kotvOIUBEjajESN7717sy15KC1asG4ZWI= +github.com/pulumi/pulumi/sdk/v3 v3.247.0/go.mod h1:67RnWlA0uRHJKjbOkk1Hqz8BNefYv8csNLgln1EmmII= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U= +github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= +github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= +github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= +github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0= +github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/collector/featuregate v1.53.0 h1:cgjXdtl7jezWxq6V0eohe/JqjY4PBotZGb5+bTR2OJw= +go.opentelemetry.io/collector/featuregate v1.53.0/go.mod h1:PS7zY/zaCb28EqciePVwRHVhc3oKortTFXsi3I6ee4g= +go.opentelemetry.io/collector/internal/testutil v0.147.0 h1:DFlRxBRp23/sZnpTITK25yqe0d56yNvK+63IaWc6OsU= +go.opentelemetry.io/collector/internal/testutil v0.147.0/go.mod h1:Jkjs6rkqs973LqgZ0Fe3zrokQRKULYXPIf4HuqStiEE= +go.opentelemetry.io/collector/pdata v1.53.0 h1:DlYDbRwammEZaxDZHINx5v0n8SEOVNniPbi6FRTlVkA= +go.opentelemetry.io/collector/pdata v1.53.0/go.mod h1:LRSYGNjKXaUrZEwZv3Yl+8/zV2HmRGKXW62zB2bysms= +go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM= +go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE= +go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/frand v1.4.2 h1:RzFIpOvkMXuPMBb9maa4ND4wjBn71E1Jpf8BzJHMaVw= +lukechampine.com/frand v1.4.2/go.mod h1:4S/TM2ZgrKejMcKMbeLjISpJMO+/eZ1zu3vYX9dtj3s= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/infra/pulumi/main.go b/infra/pulumi/main.go new file mode 100644 index 0000000..930ff9b --- /dev/null +++ b/infra/pulumi/main.go @@ -0,0 +1,189 @@ +// Pulumi program: provisions a Hetzner Cloud server (with an environment-aware cloud +// firewall) and Google Cloud DNS records for idea-collect, plus a least-privilege DNS +// service account used by Caddy for ACME DNS-01. Outputs feed the Ansible inventory. +package main + +import ( + "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/dns" + "github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/serviceaccount" + "github.com/pulumi/pulumi-hcloud/sdk/go/hcloud" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config" +) + +// cloud-init: make sure Python is present so Ansible can manage the host. +const userData = `#cloud-config +package_update: true +packages: + - python3 +` + +func main() { + pulumi.Run(func(ctx *pulumi.Context) error { + cfg := config.New(ctx, "") + environment := getOr(cfg, "environment", "prod") + domain := cfg.Require("domain") + serverType := getOr(cfg, "hcloudServerType", "cax11") + location := getOr(cfg, "hcloudLocation", "fsn1") + image := getOr(cfg, "hcloudImage", "debian-12") + sshPublicKey := cfg.Require("sshPublicKey") + dnsZoneName := cfg.Require("dnsZoneName") + createZone := cfg.GetBool("createDnsZone") + + // SSH (and dev ports) are restricted to these CIDRs; default open if unset. + sshSourceIps := []string{"0.0.0.0/0", "::/0"} + _ = cfg.GetObject("sshSourceIps", &sshSourceIps) + + gcpProject := config.New(ctx, "gcp").Require("project") + + // --- Hetzner: SSH key, firewall, server --- + sshKey, err := hcloud.NewSshKey(ctx, "idea-collect", &hcloud.SshKeyArgs{ + Name: pulumi.String("idea-collect"), + PublicKey: pulumi.String(sshPublicKey), + }) + if err != nil { + return err + } + + rules := publicRules() + if environment == "dev" { + // Expose higher dev ports, but only to the admin source IPs. + rules = append(rules, devRules(toStringArray(sshSourceIps))...) + } + rules = append(rules, &hcloud.FirewallRuleArgs{ // SSH, restricted + Direction: pulumi.String("in"), + Protocol: pulumi.String("tcp"), + Port: pulumi.String("22"), + SourceIps: toStringArray(sshSourceIps), + }) + + firewall, err := hcloud.NewFirewall(ctx, "idea-collect", &hcloud.FirewallArgs{ + Name: pulumi.String("idea-collect"), + Rules: rules, + ApplyTos: hcloud.FirewallApplyToArray{ + &hcloud.FirewallApplyToArgs{LabelSelector: pulumi.String("app=idea-collect")}, + }, + }) + if err != nil { + return err + } + + server, err := hcloud.NewServer(ctx, "idea-collect", &hcloud.ServerArgs{ + Name: pulumi.String("idea-collect-" + environment), + ServerType: pulumi.String(serverType), + Image: pulumi.String(image), + Location: pulumi.String(location), + SshKeys: pulumi.StringArray{sshKey.Name}, + UserData: pulumi.String(userData), + Labels: pulumi.StringMap{"app": pulumi.String("idea-collect"), "env": pulumi.String(environment)}, + }, pulumi.DependsOn([]pulumi.Resource{firewall})) + if err != nil { + return err + } + + // --- Google Cloud DNS: optional zone + A/AAAA records --- + zoneRef := pulumi.String(dnsZoneName).ToStringOutput() + if createZone { + zone, err := dns.NewManagedZone(ctx, "idea-collect", &dns.ManagedZoneArgs{ + Name: pulumi.String(dnsZoneName), + DnsName: pulumi.String(cfg.Require("dnsZoneDomain")), // e.g. example.com. + Description: pulumi.String("idea-collect"), + Project: pulumi.String(gcpProject), + }) + if err != nil { + return err + } + zoneRef = zone.Name + } + + fqdn := domain + "." + if _, err := dns.NewRecordSet(ctx, "idea-collect-a", &dns.RecordSetArgs{ + Name: pulumi.String(fqdn), + ManagedZone: zoneRef, + Type: pulumi.String("A"), + Ttl: pulumi.Int(300), + Rrdatas: pulumi.StringArray{server.Ipv4Address}, + Project: pulumi.String(gcpProject), + }); err != nil { + return err + } + if _, err := dns.NewRecordSet(ctx, "idea-collect-aaaa", &dns.RecordSetArgs{ + Name: pulumi.String(fqdn), + ManagedZone: zoneRef, + Type: pulumi.String("AAAA"), + Ttl: pulumi.Int(300), + Rrdatas: pulumi.StringArray{server.Ipv6Address}, + Project: pulumi.String(gcpProject), + }); err != nil { + return err + } + + // --- DNS service account for Caddy ACME DNS-01 (scoped to this zone) --- + sa, err := serviceaccount.NewAccount(ctx, "caddy-dns", &serviceaccount.AccountArgs{ + AccountId: pulumi.String("idea-collect-caddy-dns"), + DisplayName: pulumi.String("idea-collect Caddy DNS-01"), + Project: pulumi.String(gcpProject), + }) + if err != nil { + return err + } + if _, err := dns.NewDnsManagedZoneIamMember(ctx, "caddy-dns-admin", &dns.DnsManagedZoneIamMemberArgs{ + ManagedZone: zoneRef, + Role: pulumi.String("roles/dns.admin"), + Member: pulumi.Sprintf("serviceAccount:%s", sa.Email), + Project: pulumi.String(gcpProject), + }); err != nil { + return err + } + saKey, err := serviceaccount.NewKey(ctx, "caddy-dns", &serviceaccount.KeyArgs{ + ServiceAccountId: sa.Name, + }) + if err != nil { + return err + } + + // --- Outputs (consumed by infra/ansible/scripts/inventory.sh) --- + ctx.Export("environment", pulumi.String(environment)) + ctx.Export("serverIPv4", server.Ipv4Address) + ctx.Export("serverIPv6", server.Ipv6Address) + ctx.Export("domain", pulumi.String(domain)) + ctx.Export("sshUser", pulumi.String("root")) + // Base64-encoded service-account JSON; Ansible decodes it to gcp-dns-sa.json. + ctx.Export("gcpDnsSaKeyBase64", pulumi.ToSecret(saKey.PrivateKey)) + return nil + }) +} + +// publicRules returns the always-on inbound rules (HTTP, HTTPS, QUIC) open to the world. +func publicRules() hcloud.FirewallRuleArray { + all := pulumi.StringArray{pulumi.String("0.0.0.0/0"), pulumi.String("::/0")} + return hcloud.FirewallRuleArray{ + &hcloud.FirewallRuleArgs{Direction: pulumi.String("in"), Protocol: pulumi.String("tcp"), Port: pulumi.String("80"), SourceIps: all}, + &hcloud.FirewallRuleArgs{Direction: pulumi.String("in"), Protocol: pulumi.String("tcp"), Port: pulumi.String("443"), SourceIps: all}, + &hcloud.FirewallRuleArgs{Direction: pulumi.String("in"), Protocol: pulumi.String("udp"), Port: pulumi.String("443"), SourceIps: all}, + } +} + +// devRules opens the higher development ports (Vite, backend, Postgres) to admin IPs only. +func devRules(src pulumi.StringArrayInput) hcloud.FirewallRuleArray { + return hcloud.FirewallRuleArray{ + &hcloud.FirewallRuleArgs{Direction: pulumi.String("in"), Protocol: pulumi.String("tcp"), Port: pulumi.String("5173"), SourceIps: src}, + &hcloud.FirewallRuleArgs{Direction: pulumi.String("in"), Protocol: pulumi.String("tcp"), Port: pulumi.String("8080"), SourceIps: src}, + &hcloud.FirewallRuleArgs{Direction: pulumi.String("in"), Protocol: pulumi.String("tcp"), Port: pulumi.String("5432"), SourceIps: src}, + } +} + +func toStringArray(in []string) pulumi.StringArray { + out := make(pulumi.StringArray, len(in)) + for i, s := range in { + out[i] = pulumi.String(s) + } + return out +} + +func getOr(cfg *config.Config, key, def string) string { + if v := cfg.Get(key); v != "" { + return v + } + return def +}