From a001da9766de67ab45cd5881d6d53f430ba3c97e Mon Sep 17 00:00:00 2001 From: Bretton <36870434+BrettM86@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:57:00 -0700 Subject: [PATCH] Task 01: scaffold, config, errors, migrations, storage spine Go module on indigo; env-var config with logged dev defaults; typed sentinel errors (incl. ErrTombstoned for the materializer's missing-vs-deleted branch); goose migrations for ap_objects (with origin discriminator + ap_published_at), bridged_actors (frozen terminal consent, sticky handle/key), communities, inbox_events; postgres stores behind per-domain interfaces with real-DB tests. Reviewed by 4 Claude specialty agents + Codex gpt-5.5 + Gemini 3.1 Pro + GLM 5.2; 18 consolidated findings applied (critical: UpsertActor could NULL escrowed signing keys; important: identity-drift rejection, atomic SoftDelete, tombstone/missing distinction, origin column, CI-proof test skips, constraint-name error mapping). Co-Authored-By: Claude Fable 5 --- .gitignore | 15 + .golangci.yml | 27 ++ LOOP_STATE.md | 32 +- Makefile | 104 +++++++ README.md | 27 ++ cmd/tidepool/main.go | 121 ++++++++ docker-compose.dev.yml | 63 ++++ go.mod | 21 ++ go.sum | 46 +++ internal/config/config.go | 106 +++++++ internal/config/config_test.go | 92 ++++++ internal/db/db.go | 42 +++ internal/db/migrate.go | 55 ++++ .../db/migrations/001_create_ap_objects.sql | 32 ++ .../migrations/002_create_bridged_actors.sql | 32 ++ .../db/migrations/003_create_communities.sql | 27 ++ .../db/migrations/004_create_inbox_events.sql | 19 ++ internal/errors/errors.go | 104 +++++++ internal/errors/errors_test.go | 56 ++++ internal/store/ap_objects.go | 205 +++++++++++++ internal/store/ap_objects_test.go | 208 +++++++++++++ internal/store/bridged_actors.go | 209 +++++++++++++ internal/store/bridged_actors_test.go | 283 ++++++++++++++++++ internal/store/communities.go | 217 ++++++++++++++ internal/store/communities_test.go | 236 +++++++++++++++ internal/store/inbox_events.go | 110 +++++++ internal/store/inbox_events_test.go | 119 ++++++++ internal/store/interfaces.go | 144 +++++++++ internal/store/migrations_test.go | 68 +++++ internal/store/models.go | 144 +++++++++ internal/store/store_test.go | 112 +++++++ 31 files changed, 3075 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/tidepool/main.go create mode 100644 docker-compose.dev.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/migrate.go create mode 100644 internal/db/migrations/001_create_ap_objects.sql create mode 100644 internal/db/migrations/002_create_bridged_actors.sql create mode 100644 internal/db/migrations/003_create_communities.sql create mode 100644 internal/db/migrations/004_create_inbox_events.sql create mode 100644 internal/errors/errors.go create mode 100644 internal/errors/errors_test.go create mode 100644 internal/store/ap_objects.go create mode 100644 internal/store/ap_objects_test.go create mode 100644 internal/store/bridged_actors.go create mode 100644 internal/store/bridged_actors_test.go create mode 100644 internal/store/communities.go create mode 100644 internal/store/communities_test.go create mode 100644 internal/store/inbox_events.go create mode 100644 internal/store/inbox_events_test.go create mode 100644 internal/store/interfaces.go create mode 100644 internal/store/migrations_test.go create mode 100644 internal/store/models.go create mode 100644 internal/store/store_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3d6177 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +/tidepool + +# Environment files with secrets +.env +.env.local + +# Editor/OS noise +.DS_Store +.idea/ +.vscode/ +*.swp + +# Go tooling +coverage.out diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e420bec --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,27 @@ +version: "2" + +linters: + default: standard # errcheck, govet, ineffassign, staticcheck, unused + enable: + - misspell + - unconvert + settings: + errcheck: + check-blank: false + staticcheck: + checks: ["all"] + +formatters: + enable: + - gofmt + - goimports + settings: + gofmt: + simplify: true + goimports: + local-prefixes: + - tidepool + +run: + timeout: 5m + tests: true diff --git a/LOOP_STATE.md b/LOOP_STATE.md index 1745fd6..70d6919 100644 --- a/LOOP_STATE.md +++ b/LOOP_STATE.md @@ -6,7 +6,7 @@ update this file → schedule next. Stop the loop when every task is `done`. | # | Task | Status | Commit | Notes | |---|------|--------|--------|-------| -| 1 | 01-scaffold-storage | pending | | | +| 1 | 01-scaffold-storage | done | (see git log) | reviewed by 7 reviewers, 18 fixes applied | | 2 | 02-ap-protocol | pending | | | | 3 | 03-identity-repos | pending | | | | 4 | 04-sync-firehose | pending | | | @@ -25,3 +25,33 @@ and deferred TODOs here) ~/Code/arroba (CC0). Coves AppView at ~/Code/coves. - Coves post consumer requires: repo DID == record.community, community indexed before post, author user indexed before post. + +### From task 01 (storage layer semantics later tasks MUST know) +- Ports: dev postgres 5442, test postgres 5443, HTTP :8091. Test DB URL: + postgres://tidepool_test:tidepool_test@localhost:5443/tidepool_test. + Containers tidepool-dev-postgres / tidepool-test-postgres. +- `PutMapping` derives at_uri itself — callers supply (DID, collection, + rkey, CID) only. Second ap_id claiming same at_uri → IsAlreadyExists + (deterministic-rkey collision = bug signal). ap_objects has an `origin` + column (fediverse|bridge) for task 06 echo suppression. +- `ResolveStrongRef` has THREE outcomes: found; IsNotFound (missing → + task 05 fetches ancestor chain); IsTombstoned (deleted → task 05 drops + the subtree, consent-relevant). Tombstoned does NOT satisfy IsNotFound. +- `UpsertActor`/`UpsertCommunity`: identity drift (same AP id, different + DID/type/instance) → ConflictError, row untouched. Tombstoned actors + (consent_state=deleted, terminal) are fully frozen — upserts no-op and + return the stored row. Handle/signing key are sticky: omitted values + never clobber stored ones; key mutation is deliberate-only. +- ConsentState zero value ("") is INVALID by design (consent must be + stated explicitly — fails closed). Model field is SigningKeyEncrypted + (task 03 stores AES-GCM ciphertext there, column `signing_key`). +- ap_objects timestamp is `PublishedAt` (AP published), NOT created_at. +- `followed_at` stamps only on transition into accepted; AP-driven + arbitrary transitions otherwise legal. inbox_events queue-consumption + API (ListPending/ordering/attempts) deliberately deferred to task 06. +- ENVIRONMENT=production disables migrations-on-start and dev defaults. +- indigo pinned to pseudo-version v0.0.0-20260202181658-ea3d39eec464 + (same as Coves; @latest needs Go 1.26). Unique constraints have + explicit names, mapped via pq.Error.Constraint in uniqueViolation(). +- pr-review-toolkit plugin agents unavailable in this session — the loop + emulates them with general-purpose agents (works fine; keep doing it). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7207a81 --- /dev/null +++ b/Makefile @@ -0,0 +1,104 @@ +.PHONY: help build run test test-db-up test-db-down db-migrate db-migrate-down dev-up dev-down lint fmt fmt-check clean + +.DEFAULT_GOAL := help + +CYAN := \033[36m +RESET := \033[0m +GREEN := \033[32m +YELLOW := \033[33m +RED := \033[31m + +COMPOSE := docker compose -f docker-compose.dev.yml + +DEV_DATABASE_URL ?= postgres://tidepool:tidepool@localhost:5442/tidepool_dev?sslmode=disable +TEST_DATABASE_URL ?= postgres://tidepool_test:tidepool_test@localhost:5443/tidepool_test?sslmode=disable + +##@ General + +help: ## Show this help message + @echo "" + @echo "$(CYAN)Tidepool Development Commands$(RESET)" + @echo "" + @awk 'BEGIN {FS = ":.*##"; printf "Usage: make $(CYAN)$(RESET)\n"} \ + /^[a-zA-Z_-]+:.*?##/ { printf " $(CYAN)%-15s$(RESET) %s\n", $$1, $$2 } \ + /^##@/ { printf "\n$(YELLOW)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) + @echo "" + +##@ Build & Run + +build: ## Build the tidepool binary + @echo "$(GREEN)Building tidepool...$(RESET)" + @go build -o tidepool ./cmd/tidepool + @echo "$(GREEN)✓ Build complete: ./tidepool$(RESET)" + +run: ## Run tidepool against the dev database (requires: make dev-up) + @go run ./cmd/tidepool + +##@ Local Development + +dev-up: ## Start the dev database (port 5442) + @echo "$(GREEN)Starting Tidepool dev database...$(RESET)" + @$(COMPOSE) up -d --wait postgres + @echo "$(GREEN)✓ PostgreSQL (dev) on localhost:5442$(RESET)" + +dev-down: ## Stop all dev services (including the test database) + @echo "$(YELLOW)Stopping Tidepool dev stack...$(RESET)" + @$(COMPOSE) --profile test down --remove-orphans + @echo "$(GREEN)✓ Stopped$(RESET)" + +##@ Database Management + +db-migrate: ## Apply migrations to the dev database (goose CLI) + @echo "$(GREEN)Running migrations...$(RESET)" + @goose -dir internal/db/migrations postgres "$(DEV_DATABASE_URL)" up + @echo "$(GREEN)✓ Migrations complete$(RESET)" + +db-migrate-down: ## Roll back the last migration on the dev database + @echo "$(YELLOW)Rolling back last migration...$(RESET)" + @goose -dir internal/db/migrations postgres "$(DEV_DATABASE_URL)" down + @echo "$(GREEN)✓ Rollback complete$(RESET)" + +##@ Testing + +test-db-up: ## Start the test database (port 5443) + @echo "$(GREEN)Starting test database...$(RESET)" + @$(COMPOSE) --profile test up -d --wait postgres-test + @echo "$(GREEN)✓ PostgreSQL (test) on localhost:5443$(RESET)" + +test-db-down: ## Stop the test database + @$(COMPOSE) --profile test stop postgres-test + @echo "$(GREEN)✓ Test database stopped$(RESET)" + +test: test-db-up ## Run the test suite against real postgres (migrations run in-process) + @echo "$(GREEN)Running tests (store tests migrate the test database themselves)...$(RESET)" + @TIDEPOOL_TEST_DATABASE_URL="$(TEST_DATABASE_URL)" go test ./... + @echo "$(GREEN)✓ Tests complete$(RESET)" + +##@ Code Quality + +fmt: ## Format all Go code + @echo "$(GREEN)Formatting Go code...$(RESET)" + @gofmt -w ./cmd ./internal + @echo "$(GREEN)✓ Formatting complete$(RESET)" + +fmt-check: ## Check formatting without writing + @unformatted=$$(gofmt -l ./cmd ./internal); \ + if [ -n "$$unformatted" ]; then \ + echo "$(RED)✗ Unformatted files:$(RESET)"; \ + echo "$$unformatted"; \ + echo "$(YELLOW)Run 'make fmt' to fix$(RESET)"; \ + exit 1; \ + fi + @echo "$(GREEN)✓ All files formatted$(RESET)" + +lint: fmt-check ## Run golangci-lint (includes format check) + @echo "$(GREEN)Running linter...$(RESET)" + @golangci-lint run ./... + @echo "$(GREEN)✓ Linting complete$(RESET)" + +##@ Cleanup + +clean: ## Remove build artifacts + @rm -f tidepool + @go clean + @echo "$(GREEN)✓ Clean complete$(RESET)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..146f80f --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Tidepool + +Tidepool is a read-only ActivityPub → atproto bridge for the threadiverse. +It follows Lemmy/PieFed/Mbin communities (FEP-1b12 group federation), +materializes their posts, comments, and profiles as `social.coves.*` records +in a virtual PDS it operates itself, and serves them over +`com.atproto.sync.*` so the [Coves](https://github.com/coves-social) AppView +indexes fediverse communities exactly as it indexes native ones. Votes stay +bridge-side as aggregates behind one sanctioned XRPC. + +See **[PLAN.md](PLAN.md)** for the architecture, locked design decisions, +and the task-by-task build plan (`tasks/`). + +## Quick start + +```sh +make dev-up # start the dev postgres (localhost:5442) +make run # run the bridge (migrations apply on start in dev) +make test # start the test postgres (localhost:5443) and run the suite +``` + +Requires Go 1.25+, Docker, and (for `make db-migrate` / `make lint`) the +`goose` and `golangci-lint` CLIs. Store tests need a real postgres: they +skip with a clear message when `TIDEPOOL_TEST_DATABASE_URL` is unset. + +Configuration is environment variables with logged dev defaults — see +`internal/config/config.go`. diff --git a/cmd/tidepool/main.go b/cmd/tidepool/main.go new file mode 100644 index 0000000..452bab5 --- /dev/null +++ b/cmd/tidepool/main.go @@ -0,0 +1,121 @@ +// Command tidepool runs the ActivityPub→atproto bridge. main stays thin: +// config, database, migrations (dev only), a chi router that later tasks +// register their subsystems on, and graceful shutdown. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "tidepool/internal/config" + "tidepool/internal/db" +) + +const ( + readHeaderTimeout = 10 * time.Second + writeTimeout = 30 * time.Second + idleTimeout = 2 * time.Minute + shutdownTimeout = 15 * time.Second +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + slog.SetDefault(logger) + + if err := run(logger); err != nil { + logger.Error("tidepool exited with error", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg, err := config.Load(logger) + if err != nil { + return err + } + + database, err := db.Open(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer func() { _ = database.Close() }() + + if cfg.IsDevelopment() { + logger.Info("development environment: applying migrations on start") + if err := db.MigrateUp(ctx, database); err != nil { + return err + } + } + + router := chi.NewRouter() + router.Use(middleware.RequestID) + router.Use(middleware.Recoverer) + + router.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + if err := database.PingContext(r.Context()); err != nil { + logger.Error("health check failed", "error", err) + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + // Later tasks register here: AP inbox + WebFinger (02/06), + // com.atproto.sync.* + subscribeRepos (04), vote aggregates XRPC (07). + + server := &http.Server{ + Addr: cfg.ListenAddr, + Handler: router, + ReadHeaderTimeout: readHeaderTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } + + serverErrors := make(chan error, 1) + go func() { + logger.Info("tidepool listening", + "addr", cfg.ListenAddr, + "environment", cfg.Environment, + "bridge_hostname", cfg.BridgeHostname, + ) + serverErrors <- server.ListenAndServe() + }() + + select { + case err := <-serverErrors: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server: %w", err) + } + return nil + case <-ctx.Done(): + logger.Info("shutdown signal received, draining connections") + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("graceful shutdown: %w", err) + } + // ListenAndServe has returned by now (Shutdown guarantees it); + // drain its error so a bind failure racing the signal still exits + // non-zero instead of being lost in the buffered channel. + if err := <-serverErrors; err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server: %w", err) + } + logger.Info("shutdown complete") + return nil + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..ef1a8e4 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,63 @@ +# Tidepool local development stack (Coves-style profiles). +# +# Usage: +# make dev-up # start the dev database (port 5442) +# make test # starts postgres-test (port 5443) and runs the suite +# make dev-down # stop everything +# +# Ports 5442/5443 are chosen to stay clear of the Coves stack +# (5434 test, 5435 dev, 5436 plc). + +services: + # Development database (port 5442) + postgres: + image: postgres:16 + container_name: tidepool-dev-postgres + ports: + - "5442:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-tidepool_dev} + POSTGRES_USER: ${POSTGRES_USER:-tidepool} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tidepool} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - tidepool-dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tidepool} -d ${POSTGRES_DB:-tidepool_dev}"] + interval: 5s + timeout: 5s + retries: 5 + + # Test database (port 5443) — used by `make test` + postgres-test: + image: postgres:16 + container_name: tidepool-test-postgres + ports: + - "${POSTGRES_TEST_PORT:-5443}:5432" + environment: + POSTGRES_DB: ${POSTGRES_TEST_DB:-tidepool_test} + POSTGRES_USER: ${POSTGRES_TEST_USER:-tidepool_test} + POSTGRES_PASSWORD: ${POSTGRES_TEST_PASSWORD:-tidepool_test} + volumes: + - postgres-test-data:/var/lib/postgresql/data + networks: + - tidepool-dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_TEST_USER:-tidepool_test} -d ${POSTGRES_TEST_DB:-tidepool_test}"] + interval: 2s + timeout: 5s + retries: 15 + profiles: + - test + +networks: + tidepool-dev: + driver: bridge + name: tidepool-dev-network + +volumes: + postgres-data: + name: tidepool-dev-postgres-data + postgres-test-data: + name: tidepool-test-postgres-data diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8efa3bc --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module tidepool + +go 1.25.7 + +require ( + github.com/bluesky-social/indigo v0.0.0-20260202181658-ea3d39eec464 + github.com/go-chi/chi/v5 v5.3.1 + github.com/lib/pq v1.12.3 + github.com/pressly/goose/v3 v3.27.2 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..34165a6 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +github.com/bluesky-social/indigo v0.0.0-20260202181658-ea3d39eec464 h1:jL6cPOk1CZ8H06sEn+WFGWufHmqkawsGyDRl+BJhQjs= +github.com/bluesky-social/indigo v0.0.0-20260202181658-ea3d39eec464/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +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/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= +github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c06c122 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,106 @@ +// Package config loads Tidepool configuration from environment variables. +// In development, missing values fall back to logged dev defaults (Coves +// style, no config library). In production, required values must be set. +package config + +import ( + "fmt" + "log/slog" + "os" +) + +const ( + EnvironmentDevelopment = "development" + EnvironmentProduction = "production" +) + +// Config holds all runtime configuration for the bridge. +type Config struct { + // Environment is "development" or "production". Development enables + // migrations-on-start and dev defaults for the other values. + Environment string + // DatabaseURL is the postgres connection string for bridge state. + DatabaseURL string + // ListenAddr is the address the HTTP server binds, e.g. ":8091". + ListenAddr string + // BridgeHostname is the public domain the bridge is served from, + // e.g. "tidepool.example". Used for WebFinger, actor IDs, and handles. + BridgeHostname string + // PLCDirectoryURL is the did:plc directory used to mint and resolve DIDs. + PLCDirectoryURL string + // BridgeServiceDID optionally pins a pre-provisioned service DID for the + // bridge's own actor. Empty means task 03 will mint one on first run. + BridgeServiceDID string + // UserAgent is sent on all outbound HTTP requests (signed fetches etc.). + UserAgent string +} + +// Load reads configuration from the environment. logger must not be nil; +// every dev default that gets applied is logged so local runs are explicit +// about what they picked. +func Load(logger *slog.Logger) (*Config, error) { + environment := os.Getenv("ENVIRONMENT") + if environment == "" { + environment = EnvironmentDevelopment + logger.Info("ENVIRONMENT not set, defaulting to development") + } + if environment != EnvironmentDevelopment && environment != EnvironmentProduction { + return nil, fmt.Errorf("config: ENVIRONMENT must be %q or %q, got %q", + EnvironmentDevelopment, EnvironmentProduction, environment) + } + isDevelopment := environment == EnvironmentDevelopment + + cfg := &Config{Environment: environment} + + var err error + cfg.DatabaseURL, err = stringVar(logger, isDevelopment, "DATABASE_URL", + "postgres://tidepool:tidepool@localhost:5442/tidepool_dev?sslmode=disable") + if err != nil { + return nil, err + } + cfg.ListenAddr, err = stringVar(logger, isDevelopment, "LISTEN_ADDR", ":8091") + if err != nil { + return nil, err + } + cfg.BridgeHostname, err = stringVar(logger, isDevelopment, "BRIDGE_HOSTNAME", "localhost") + if err != nil { + return nil, err + } + cfg.PLCDirectoryURL, err = stringVar(logger, isDevelopment, "PLC_DIRECTORY_URL", "http://localhost:3002") + if err != nil { + return nil, err + } + + // Optional in every environment: an operator may pre-provision the + // bridge's service DID, otherwise identity bootstrap mints one. + cfg.BridgeServiceDID = os.Getenv("BRIDGE_SERVICE_DID") + + defaultUserAgent := fmt.Sprintf("tidepool/0.1 (+https://%s)", cfg.BridgeHostname) + cfg.UserAgent = os.Getenv("USER_AGENT") + if cfg.UserAgent == "" { + cfg.UserAgent = defaultUserAgent + logger.Info("USER_AGENT not set, using default", "value", defaultUserAgent) + } + + return cfg, nil +} + +// IsDevelopment reports whether the bridge runs with dev conveniences +// (migrations-on-start, defaulted config). +func (c *Config) IsDevelopment() bool { + return c.Environment == EnvironmentDevelopment +} + +// stringVar returns the value of an environment variable. When unset it +// falls back to the logged dev default in development and errors in +// production. +func stringVar(logger *slog.Logger, isDevelopment bool, name, devDefault string) (string, error) { + if value := os.Getenv(name); value != "" { + return value, nil + } + if !isDevelopment { + return "", fmt.Errorf("config: %s is required in production", name) + } + logger.Info("environment variable not set, using dev default", "name", name, "value", devDefault) + return devDefault, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b445580 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,92 @@ +package config + +import ( + "io" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func clearConfigEnv(t *testing.T) { + t.Helper() + for _, name := range []string{ + "ENVIRONMENT", "DATABASE_URL", "LISTEN_ADDR", "BRIDGE_HOSTNAME", + "PLC_DIRECTORY_URL", "BRIDGE_SERVICE_DID", "USER_AGENT", + } { + t.Setenv(name, "") + } +} + +func TestLoad_DevelopmentDefaults(t *testing.T) { + clearConfigEnv(t) + + cfg, err := Load(discardLogger()) + require.NoError(t, err) + + assert.Equal(t, EnvironmentDevelopment, cfg.Environment) + assert.True(t, cfg.IsDevelopment()) + assert.Equal(t, "postgres://tidepool:tidepool@localhost:5442/tidepool_dev?sslmode=disable", cfg.DatabaseURL) + assert.Equal(t, ":8091", cfg.ListenAddr) + assert.Equal(t, "localhost", cfg.BridgeHostname) + assert.Equal(t, "http://localhost:3002", cfg.PLCDirectoryURL) + assert.Empty(t, cfg.BridgeServiceDID, "service DID is optional") + assert.Equal(t, "tidepool/0.1 (+https://localhost)", cfg.UserAgent) +} + +func TestLoad_ExplicitValuesWin(t *testing.T) { + clearConfigEnv(t) + t.Setenv("DATABASE_URL", "postgres://example/db") + t.Setenv("LISTEN_ADDR", ":9999") + t.Setenv("BRIDGE_HOSTNAME", "tidepool.example") + t.Setenv("PLC_DIRECTORY_URL", "https://plc.directory") + t.Setenv("BRIDGE_SERVICE_DID", "did:plc:ewvi7nxzyoun6zhxrhs64oiz") + t.Setenv("USER_AGENT", "custom-agent/1.0") + + cfg, err := Load(discardLogger()) + require.NoError(t, err) + + assert.Equal(t, "postgres://example/db", cfg.DatabaseURL) + assert.Equal(t, ":9999", cfg.ListenAddr) + assert.Equal(t, "tidepool.example", cfg.BridgeHostname) + assert.Equal(t, "https://plc.directory", cfg.PLCDirectoryURL) + assert.Equal(t, "did:plc:ewvi7nxzyoun6zhxrhs64oiz", cfg.BridgeServiceDID) + assert.Equal(t, "custom-agent/1.0", cfg.UserAgent) +} + +func TestLoad_ProductionRequiresValues(t *testing.T) { + clearConfigEnv(t) + t.Setenv("ENVIRONMENT", EnvironmentProduction) + + _, err := Load(discardLogger()) + require.Error(t, err, "production must not fall back to dev defaults") + assert.Contains(t, err.Error(), "DATABASE_URL") +} + +func TestLoad_ProductionWithAllValues(t *testing.T) { + clearConfigEnv(t) + t.Setenv("ENVIRONMENT", EnvironmentProduction) + t.Setenv("DATABASE_URL", "postgres://prod/db") + t.Setenv("LISTEN_ADDR", ":8080") + t.Setenv("BRIDGE_HOSTNAME", "tidepool.example") + t.Setenv("PLC_DIRECTORY_URL", "https://plc.directory") + + cfg, err := Load(discardLogger()) + require.NoError(t, err) + assert.False(t, cfg.IsDevelopment()) + assert.Equal(t, "tidepool/0.1 (+https://tidepool.example)", cfg.UserAgent, + "user agent default derives from the bridge hostname") +} + +func TestLoad_RejectsUnknownEnvironment(t *testing.T) { + clearConfigEnv(t) + t.Setenv("ENVIRONMENT", "staging") + + _, err := Load(discardLogger()) + require.Error(t, err) +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..b116ad9 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,42 @@ +// Package db opens the Tidepool postgres database and manages goose +// migrations (embedded so the binary and the tests can migrate without a +// goose CLI install). +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/lib/pq" // postgres driver +) + +const ( + maxOpenConnections = 25 + maxIdleConnections = 5 + connectionLifetime = 5 * time.Minute + pingTimeout = 5 * time.Second +) + +// Open connects to postgres, applies pool settings, and verifies the +// connection with a ping bounded by ctx. +func Open(ctx context.Context, databaseURL string) (*sql.DB, error) { + database, err := sql.Open("postgres", databaseURL) + if err != nil { + return nil, fmt.Errorf("db: open: %w", err) + } + + database.SetMaxOpenConns(maxOpenConnections) + database.SetMaxIdleConns(maxIdleConnections) + database.SetConnMaxLifetime(connectionLifetime) + + pingCtx, cancel := context.WithTimeout(ctx, pingTimeout) + defer cancel() + if err := database.PingContext(pingCtx); err != nil { + _ = database.Close() + return nil, fmt.Errorf("db: ping: %w", err) + } + + return database, nil +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..49981f7 --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,55 @@ +package db + +import ( + "context" + "database/sql" + "embed" + "fmt" + "io/fs" + + "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/database" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// newMigrationProvider builds a goose provider over the embedded migration +// files. Providers hold no global state, so tests can create their own. +func newMigrationProvider(conn *sql.DB) (*goose.Provider, error) { + migrations, err := fs.Sub(migrationsFS, "migrations") + if err != nil { + return nil, fmt.Errorf("db: sub filesystem: %w", err) + } + provider, err := goose.NewProvider(database.DialectPostgres, conn, migrations) + if err != nil { + return nil, fmt.Errorf("db: migration provider: %w", err) + } + return provider, nil +} + +// MigrateUp applies all pending embedded migrations. +func MigrateUp(ctx context.Context, conn *sql.DB) error { + provider, err := newMigrationProvider(conn) + if err != nil { + return err + } + if _, err := provider.Up(ctx); err != nil { + return fmt.Errorf("db: migrate up: %w", err) + } + return nil +} + +// MigrateDownTo rolls migrations back down to (and including) the given +// version; version 0 tears everything down. Used by tests to verify the +// down migrations actually work. +func MigrateDownTo(ctx context.Context, conn *sql.DB, version int64) error { + provider, err := newMigrationProvider(conn) + if err != nil { + return err + } + if _, err := provider.DownTo(ctx, version); err != nil { + return fmt.Errorf("db: migrate down to %d: %w", version, err) + } + return nil +} diff --git a/internal/db/migrations/001_create_ap_objects.sql b/internal/db/migrations/001_create_ap_objects.sql new file mode 100644 index 0000000..fd15f7b --- /dev/null +++ b/internal/db/migrations/001_create_ap_objects.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- ap_objects is the spine of the bridge: the bidirectional mapping between +-- ActivityPub object IDs and the atproto records they were materialized as. +-- Every materialization writes a row; every strongRef resolution reads one. +-- Unique constraints carry explicit names so the store layer can map +-- SQLSTATE 23505 violations to precise conflict errors. +CREATE TABLE ap_objects ( + id BIGSERIAL PRIMARY KEY, + ap_id TEXT NOT NULL CHECK (ap_id <> ''), -- canonical AP object id (URL) + ap_type TEXT NOT NULL CHECK (ap_type <> ''), -- AP type: Page, Note, Group, Person, ... + origin_instance TEXT NOT NULL CHECK (origin_instance <> ''), -- host the object originated from, e.g. lemmy.world + origin TEXT NOT NULL DEFAULT 'fediverse' + CHECK (origin IN ('fediverse', 'bridge')), -- which side authored the object (echo suppression, task 06) + did TEXT NOT NULL CHECK (did <> ''), -- repo the record was written into + collection TEXT NOT NULL CHECK (collection <> ''), -- record NSID, e.g. social.coves.community.post + rkey TEXT NOT NULL CHECK (rkey <> ''), -- deterministic TID rkey + at_uri TEXT NOT NULL CHECK (at_uri <> ''), -- at://did/collection/rkey + cid TEXT NOT NULL CHECK (cid <> ''), -- CID of the current record version + ap_published_at TIMESTAMPTZ, -- AP `published` time (may be absent upstream) + indexed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ, -- soft delete (AP Delete / Tombstone) + CONSTRAINT ap_objects_ap_id_key UNIQUE (ap_id), + CONSTRAINT ap_objects_at_uri_key UNIQUE (at_uri) +); + +CREATE INDEX idx_ap_objects_did_collection ON ap_objects (did, collection); +CREATE INDEX idx_ap_objects_origin_instance ON ap_objects (origin_instance); + +-- +goose Down +DROP INDEX IF EXISTS idx_ap_objects_origin_instance; +DROP INDEX IF EXISTS idx_ap_objects_did_collection; +DROP TABLE IF EXISTS ap_objects; diff --git a/internal/db/migrations/002_create_bridged_actors.sql b/internal/db/migrations/002_create_bridged_actors.sql new file mode 100644 index 0000000..6d5e28f --- /dev/null +++ b/internal/db/migrations/002_create_bridged_actors.sql @@ -0,0 +1,32 @@ +-- +goose Up +-- bridged_actors registers every AP actor (person or group) that Tidepool +-- has minted an atproto identity for, including the escrowed signing key. +-- NOTE: signing_key holds AES-GCM-encrypted secp256k1 key material once +-- task 03 lands; the column is bytea so encryption needs no schema change. +-- Unique constraints carry explicit names so the store layer can map +-- SQLSTATE 23505 violations to precise conflict errors. +CREATE TABLE bridged_actors ( + id BIGSERIAL PRIMARY KEY, + ap_actor_id TEXT NOT NULL CHECK (ap_actor_id <> ''), -- canonical AP actor id (URL) + actor_type TEXT NOT NULL CHECK (actor_type IN ('person', 'group')), + did TEXT NOT NULL CHECK (did <> ''), + handle TEXT, -- bridged handle, e.g. user.lemmy-world.tidepool.example + signing_key BYTEA, -- escrowed signing key (AES-GCM ciphertext from task 03 on) + consent_state TEXT NOT NULL DEFAULT 'ok' + CHECK (consent_state IN ('ok', 'nobridge', 'deleted')), + profile_synced_at TIMESTAMPTZ, -- last time the profile record was (re)materialized + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT bridged_actors_ap_actor_id_key UNIQUE (ap_actor_id), + CONSTRAINT bridged_actors_did_key UNIQUE (did) +); + +-- atproto handles are 1:1 with DIDs; NULL means "not yet assigned" and is +-- allowed to repeat, hence the partial index. +CREATE UNIQUE INDEX bridged_actors_handle_key ON bridged_actors (handle) WHERE handle IS NOT NULL; + +CREATE INDEX idx_bridged_actors_consent_state ON bridged_actors (consent_state); + +-- +goose Down +DROP INDEX IF EXISTS idx_bridged_actors_consent_state; +DROP INDEX IF EXISTS bridged_actors_handle_key; +DROP TABLE IF EXISTS bridged_actors; diff --git a/internal/db/migrations/003_create_communities.sql b/internal/db/migrations/003_create_communities.sql new file mode 100644 index 0000000..5eb3744 --- /dev/null +++ b/internal/db/migrations/003_create_communities.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- communities tracks the AP groups the bridge follows (community +-- subscriptions) and their backfill progress. +-- Unique constraints carry explicit names so the store layer can map +-- SQLSTATE 23505 violations to precise conflict errors. +CREATE TABLE communities ( + id BIGSERIAL PRIMARY KEY, + ap_group_id TEXT NOT NULL CHECK (ap_group_id <> ''), -- canonical AP Group actor id (URL) + did TEXT NOT NULL CHECK (did <> ''), -- the community's bridged repo DID + preferred_username TEXT NOT NULL CHECK (preferred_username <> ''), + instance TEXT NOT NULL CHECK (instance <> ''), -- host, e.g. lemmy.world + follow_state TEXT NOT NULL DEFAULT 'none' + CHECK (follow_state IN ('none', 'pending', 'accepted')), + followed_at TIMESTAMPTZ, -- when the Follow was accepted + last_backfill_at TIMESTAMPTZ, -- last completed outbox backfill + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT communities_ap_group_id_key UNIQUE (ap_group_id), + CONSTRAINT communities_did_key UNIQUE (did) +); + +CREATE INDEX idx_communities_instance ON communities (instance); +CREATE INDEX idx_communities_follow_state ON communities (follow_state); + +-- +goose Down +DROP INDEX IF EXISTS idx_communities_follow_state; +DROP INDEX IF EXISTS idx_communities_instance; +DROP TABLE IF EXISTS communities; diff --git a/internal/db/migrations/004_create_inbox_events.sql b/internal/db/migrations/004_create_inbox_events.sql new file mode 100644 index 0000000..9d3bd8a --- /dev/null +++ b/internal/db/migrations/004_create_inbox_events.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- inbox_events deduplicates inbound AP activities by id and records +-- processing outcomes. The unique activity_id is the dedupe key: a second +-- delivery of the same activity inserts nothing. +CREATE TABLE inbox_events ( + id BIGSERIAL PRIMARY KEY, + activity_id TEXT NOT NULL CHECK (activity_id <> ''), -- AP activity id (URL) + type TEXT NOT NULL CHECK (type <> ''), -- AP activity type: Announce, Create, Like, ... + received_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + processed_at TIMESTAMPTZ, -- NULL until successfully processed + error TEXT, -- last processing error, if any + CONSTRAINT inbox_events_activity_id_key UNIQUE (activity_id) +); + +CREATE INDEX idx_inbox_events_unprocessed ON inbox_events (received_at) WHERE processed_at IS NULL; + +-- +goose Down +DROP INDEX IF EXISTS idx_inbox_events_unprocessed; +DROP TABLE IF EXISTS inbox_events; diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..7c44288 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,104 @@ +// Package errors provides sentinel and typed errors shared across Tidepool. +// It mirrors the Coves error conventions: sentinel values for errors.Is +// checks, small typed errors carrying context, and helpers for the common +// classification questions. Typed errors unwrap to their matching sentinel, +// so errors.Is(err, ErrNotFound) works on a NotFoundError and anything +// wrapping one with %w. +package errors + +import ( + "errors" + "fmt" +) + +var ( + ErrNotFound = errors.New("resource not found") + ErrAlreadyExists = errors.New("resource already exists") + ErrInvalidInput = errors.New("invalid input") + // ErrTombstoned marks a resource that existed but was soft-deleted + // (AP Delete / Tombstone). It is deliberately distinct from ErrNotFound: + // a missing object may be fetched and materialized, a tombstoned one + // must not be. IsNotFound(tombstoned) is false. + ErrTombstoned = errors.New("resource tombstoned") +) + +// ValidationError reports a rejected field value. +type ValidationError struct { + Field string + Message string +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message) +} + +// Unwrap makes errors.Is(err, ErrInvalidInput) true for validation errors. +func (e ValidationError) Unwrap() error { return ErrInvalidInput } + +// NotFoundError reports a missing resource with its identifier. +type NotFoundError struct { + Resource string + ID any +} + +func (e NotFoundError) Error() string { + return fmt.Sprintf("%s with ID '%v' not found", e.Resource, e.ID) +} + +// Unwrap makes errors.Is(err, ErrNotFound) true for not-found errors. +func (e NotFoundError) Unwrap() error { return ErrNotFound } + +// ConflictError reports a uniqueness conflict on a resource field. +type ConflictError struct { + Resource string + Field string + Value string +} + +func (e ConflictError) Error() string { + return fmt.Sprintf("%s with %s '%s' already exists", e.Resource, e.Field, e.Value) +} + +// Unwrap makes errors.Is(err, ErrAlreadyExists) true for conflict errors. +func (e ConflictError) Unwrap() error { return ErrAlreadyExists } + +// TombstonedError reports a soft-deleted resource with its identifier. +type TombstonedError struct { + Resource string + ID any +} + +func (e TombstonedError) Error() string { + return fmt.Sprintf("%s with ID '%v' is tombstoned", e.Resource, e.ID) +} + +// Unwrap makes errors.Is(err, ErrTombstoned) true for tombstoned errors. +func (e TombstonedError) Unwrap() error { return ErrTombstoned } + +func NewValidationError(field, message string) error { + return ValidationError{Field: field, Message: message} +} + +func NewNotFoundError(resource string, id any) error { + return NotFoundError{Resource: resource, ID: id} +} + +func NewConflictError(resource, field, value string) error { + return ConflictError{Resource: resource, Field: field, Value: value} +} + +func NewTombstonedError(resource string, id any) error { + return TombstonedError{Resource: resource, ID: id} +} + +// IsNotFound reports whether err is, wraps, or unwraps to ErrNotFound. +func IsNotFound(err error) bool { return errors.Is(err, ErrNotFound) } + +// IsAlreadyExists reports whether err is, wraps, or unwraps to ErrAlreadyExists. +func IsAlreadyExists(err error) bool { return errors.Is(err, ErrAlreadyExists) } + +// IsValidation reports whether err is, wraps, or unwraps to ErrInvalidInput. +func IsValidation(err error) bool { return errors.Is(err, ErrInvalidInput) } + +// IsTombstoned reports whether err is, wraps, or unwraps to ErrTombstoned. +func IsTombstoned(err error) bool { return errors.Is(err, ErrTombstoned) } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go new file mode 100644 index 0000000..8ea6de8 --- /dev/null +++ b/internal/errors/errors_test.go @@ -0,0 +1,56 @@ +package errors + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTypedErrorsUnwrapToSentinels(t *testing.T) { + assert.True(t, IsNotFound(NewNotFoundError("ap_object", "https://lemmy.world/post/1"))) + assert.True(t, IsAlreadyExists(NewConflictError("bridged_actor", "did", "did:plc:abc"))) + assert.True(t, IsValidation(NewValidationError("did", "must not be empty"))) + assert.True(t, IsTombstoned(NewTombstonedError("ap_object", "https://lemmy.world/post/1"))) +} + +func TestTombstonedIsDistinctFromNotFound(t *testing.T) { + // The materializer branches on this distinction: missing objects are + // fetched, tombstoned ones are dropped. Neither may masquerade as the + // other. + tombstoned := NewTombstonedError("ap_object", "https://lemmy.world/post/1") + assert.False(t, IsNotFound(tombstoned), "tombstoned must not satisfy IsNotFound") + + missing := NewNotFoundError("ap_object", "https://lemmy.world/post/1") + assert.False(t, IsTombstoned(missing), "not-found must not satisfy IsTombstoned") + + wrapped := fmt.Errorf("resolve strongRef: %w", tombstoned) + assert.True(t, IsTombstoned(wrapped)) +} + +func TestHelpersMatchWrappedErrors(t *testing.T) { + wrapped := fmt.Errorf("resolve strongRef: %w", NewNotFoundError("ap_object", "x")) + assert.True(t, IsNotFound(wrapped), "%%w-wrapped typed errors must still match") + + doublyWrapped := fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", ErrAlreadyExists)) + assert.True(t, IsAlreadyExists(doublyWrapped)) + + assert.False(t, IsNotFound(ErrInvalidInput)) + assert.False(t, IsAlreadyExists(nil)) + assert.False(t, IsValidation(fmt.Errorf("plain error"))) +} + +func TestErrorMessages(t *testing.T) { + assert.Equal(t, + "validation error on field 'did': must not be empty", + NewValidationError("did", "must not be empty").Error()) + assert.Equal(t, + "ap_object with ID 'https://x/1' not found", + NewNotFoundError("ap_object", "https://x/1").Error()) + assert.Equal(t, + "community with did 'did:plc:abc' already exists", + NewConflictError("community", "did", "did:plc:abc").Error()) + assert.Equal(t, + "ap_object with ID 'https://x/1' is tombstoned", + NewTombstonedError("ap_object", "https://x/1").Error()) +} diff --git a/internal/store/ap_objects.go b/internal/store/ap_objects.go new file mode 100644 index 0000000..e55157e --- /dev/null +++ b/internal/store/ap_objects.go @@ -0,0 +1,205 @@ +package store + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/lib/pq" + + "tidepool/internal/errors" +) + +type postgresAPObjects struct { + db *sql.DB +} + +// NewAPObjects creates the postgres-backed ap_objects repository. +func NewAPObjects(db *sql.DB) APObjects { + return &postgresAPObjects{db: db} +} + +const apObjectColumns = ` + id, ap_id, ap_type, origin_instance, origin, did, collection, rkey, + at_uri, cid, ap_published_at, indexed_at, deleted_at` + +func (r *postgresAPObjects) PutMapping(ctx context.Context, mapping APObjectMapping) (*APObjectMapping, error) { + if err := validateMapping(&mapping); err != nil { + return nil, err + } + + query := ` + INSERT INTO ap_objects ( + ap_id, ap_type, origin_instance, origin, did, collection, rkey, + at_uri, cid, ap_published_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (ap_id) DO UPDATE SET + ap_type = EXCLUDED.ap_type, + origin = EXCLUDED.origin, + did = EXCLUDED.did, + collection = EXCLUDED.collection, + rkey = EXCLUDED.rkey, + at_uri = EXCLUDED.at_uri, + cid = EXCLUDED.cid, + ap_published_at = EXCLUDED.ap_published_at, + indexed_at = CURRENT_TIMESTAMP, + deleted_at = NULL + RETURNING` + apObjectColumns + + row := r.db.QueryRowContext(ctx, query, + mapping.APID, mapping.APType, mapping.OriginInstance, string(mapping.Origin), + mapping.DID, mapping.Collection, mapping.RKey, + mapping.ATURI, mapping.CID, mapping.PublishedAt, + ) + stored, err := scanAPObject(row) + if err != nil { + // ap_id conflicts are handled by the upsert, so the only expected + // unique violation is the at_uri constraint: a different AP object + // already claimed this at-uri. Deterministic rkeys make this a + // caller bug. + if constraint, ok := uniqueViolation(err); ok && constraint == "ap_objects_at_uri_key" { + return nil, errors.NewConflictError("ap_object", "at_uri", mapping.ATURI) + } + return nil, fmt.Errorf("put ap_object mapping for %q: %w", mapping.APID, err) + } + return stored, nil +} + +func (r *postgresAPObjects) GetByAPID(ctx context.Context, apID string) (*APObjectMapping, error) { + query := `SELECT` + apObjectColumns + ` FROM ap_objects WHERE ap_id = $1` + mapping, err := scanAPObject(r.db.QueryRowContext(ctx, query, apID)) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("ap_object", apID) + } + return nil, fmt.Errorf("get ap_object by ap_id %q: %w", apID, err) + } + return mapping, nil +} + +func (r *postgresAPObjects) GetByATURI(ctx context.Context, atURI string) (*APObjectMapping, error) { + query := `SELECT` + apObjectColumns + ` FROM ap_objects WHERE at_uri = $1` + mapping, err := scanAPObject(r.db.QueryRowContext(ctx, query, atURI)) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("ap_object", atURI) + } + return nil, fmt.Errorf("get ap_object by at_uri %q: %w", atURI, err) + } + return mapping, nil +} + +func (r *postgresAPObjects) ResolveStrongRef(ctx context.Context, apID string) (string, string, error) { + query := `SELECT at_uri, cid, deleted_at FROM ap_objects WHERE ap_id = $1` + + var atURI, cid string + var deletedAt sql.NullTime + err := r.db.QueryRowContext(ctx, query, apID).Scan(&atURI, &cid, &deletedAt) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return "", "", errors.NewNotFoundError("ap_object", apID) + } + return "", "", fmt.Errorf("resolve strongRef for %q: %w", apID, err) + } + if deletedAt.Valid { + return "", "", errors.NewTombstonedError("ap_object", apID) + } + return atURI, cid, nil +} + +func (r *postgresAPObjects) SoftDelete(ctx context.Context, apID string) error { + // COALESCE keeps the original tombstone time on re-delete, making the + // whole operation a single atomic statement: affected == 0 can only + // mean the row does not exist. + query := ` + UPDATE ap_objects + SET deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP) + WHERE ap_id = $1` + + result, err := r.db.ExecContext(ctx, query, apID) + if err != nil { + return fmt.Errorf("soft delete ap_object %q: %w", apID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("soft delete ap_object %q: rows affected: %w", apID, err) + } + if affected == 0 { + return errors.NewNotFoundError("ap_object", apID) + } + return nil +} + +// validateMapping checks the atproto identifiers with indigo's syntax +// package, defaults Origin to fediverse, and derives ATURI from +// (DID, Collection, RKey). +func validateMapping(mapping *APObjectMapping) error { + if mapping.APID == "" { + return errors.NewValidationError("ap_id", "must not be empty") + } + if mapping.APType == "" { + return errors.NewValidationError("ap_type", "must not be empty") + } + if mapping.OriginInstance == "" { + return errors.NewValidationError("origin_instance", "must not be empty") + } + if mapping.Origin == "" { + mapping.Origin = OriginFediverse + } + if !mapping.Origin.Valid() { + return errors.NewValidationError("origin", + fmt.Sprintf("must be %q or %q, got %q", OriginFediverse, OriginBridge, mapping.Origin)) + } + if _, err := syntax.ParseCID(mapping.CID); err != nil { + return errors.NewValidationError("cid", err.Error()) + } + did, err := syntax.ParseDID(mapping.DID) + if err != nil { + return errors.NewValidationError("did", err.Error()) + } + collection, err := syntax.ParseNSID(mapping.Collection) + if err != nil { + return errors.NewValidationError("collection", err.Error()) + } + rkey, err := syntax.ParseRecordKey(mapping.RKey) + if err != nil { + return errors.NewValidationError("rkey", err.Error()) + } + mapping.ATURI = fmt.Sprintf("at://%s/%s/%s", did, collection, rkey) + return nil +} + +// rowScanner covers *sql.Row and *sql.Rows. +type rowScanner interface { + Scan(destinations ...any) error +} + +func scanAPObject(row rowScanner) (*APObjectMapping, error) { + var mapping APObjectMapping + var origin string + err := row.Scan( + &mapping.ID, &mapping.APID, &mapping.APType, &mapping.OriginInstance, + &origin, &mapping.DID, &mapping.Collection, &mapping.RKey, + &mapping.ATURI, &mapping.CID, + &mapping.PublishedAt, &mapping.IndexedAt, &mapping.DeletedAt, + ) + if err != nil { + return nil, err + } + mapping.Origin = Origin(origin) + return &mapping, nil +} + +// uniqueViolation reports whether err is a postgres unique constraint +// violation (SQLSTATE 23505) and, if so, which named constraint fired. +// Callers map known constraint names to precise conflict errors and let +// unknown ones fall through as wrapped internal errors. +func uniqueViolation(err error) (constraint string, ok bool) { + var pqError *pq.Error + if stderrors.As(err, &pqError) && pqError.Code == "23505" { + return pqError.Constraint, true + } + return "", false +} diff --git a/internal/store/ap_objects_test.go b/internal/store/ap_objects_test.go new file mode 100644 index 0000000..481887a --- /dev/null +++ b/internal/store/ap_objects_test.go @@ -0,0 +1,208 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" +) + +func TestAPObjects_PutMapping_InsertAndDeriveATURI(t *testing.T) { + repo := NewAPObjects(testDB(t)) + ctx := context.Background() + + stored, err := repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) + + assert.Equal(t, testExpectedATURI, stored.ATURI, "ATURI must be derived from did/collection/rkey") + assert.Equal(t, testAPObjectID, stored.APID) + assert.Equal(t, testCID, stored.CID) + assert.NotZero(t, stored.ID) + assert.NotZero(t, stored.IndexedAt) + assert.Nil(t, stored.DeletedAt) + require.NotNil(t, stored.PublishedAt) +} + +func TestAPObjects_PutMapping_OriginDefaultsToFediverse(t *testing.T) { + repo := NewAPObjects(testDB(t)) + ctx := context.Background() + + // An empty origin means "fediverse" — the common case for ingested + // content; only bridge-emitted writes state OriginBridge explicitly. + stored, err := repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) + assert.Equal(t, OriginFediverse, stored.Origin) + + bridged := testMapping() + bridged.APID = "https://tidepool.example/objects/echo-1" + bridged.RKey = "3jzfcijpj2z3a" + bridged.Origin = OriginBridge + storedBridge, err := repo.PutMapping(ctx, bridged) + require.NoError(t, err) + assert.Equal(t, OriginBridge, storedBridge.Origin) + + // Origin updates on re-put like the other mutable fields. + bridged.Origin = "" + storedBridge, err = repo.PutMapping(ctx, bridged) + require.NoError(t, err) + assert.Equal(t, OriginFediverse, storedBridge.Origin, "empty origin defaults to fediverse on update too") +} + +func TestAPObjects_PutMapping_UpsertIsIdempotent(t *testing.T) { + database := testDB(t) + repo := NewAPObjects(database) + ctx := context.Background() + + first, err := repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) + + // Re-ingesting the same AP object (now at a new record version) must + // update in place, not create a second row. + updated := testMapping() + updated.CID = testUpdatedCID + second, err := repo.PutMapping(ctx, updated) + require.NoError(t, err) + + assert.Equal(t, first.ID, second.ID, "upsert must reuse the existing row") + assert.Equal(t, first.ATURI, second.ATURI, "deterministic rkeys keep the at-uri stable") + assert.Equal(t, testUpdatedCID, second.CID) + assert.False(t, second.IndexedAt.Before(first.IndexedAt), "indexed_at must refresh on upsert") + + var total int + require.NoError(t, database.QueryRow(`SELECT COUNT(*) FROM ap_objects`).Scan(&total)) + assert.Equal(t, 1, total, "idempotent re-ingestion must not create extra rows") +} + +func TestAPObjects_GetByAPIDAndATURI(t *testing.T) { + repo := NewAPObjects(testDB(t)) + ctx := context.Background() + + stored, err := repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) + + byAPID, err := repo.GetByAPID(ctx, testAPObjectID) + require.NoError(t, err) + assert.Equal(t, stored.ID, byAPID.ID) + + byATURI, err := repo.GetByATURI(ctx, stored.ATURI) + require.NoError(t, err) + assert.Equal(t, stored.ID, byATURI.ID) + + _, err = repo.GetByAPID(ctx, "https://lemmy.world/post/does-not-exist") + assert.True(t, errors.IsNotFound(err), "miss must satisfy IsNotFound, got %v", err) + + _, err = repo.GetByATURI(ctx, "at://did:plc:missing/social.coves.community.post/3aaaaaaaaaa2a") + assert.True(t, errors.IsNotFound(err), "miss must satisfy IsNotFound, got %v", err) +} + +func TestAPObjects_ResolveStrongRef(t *testing.T) { + repo := NewAPObjects(testDB(t)) + ctx := context.Background() + + // Branch 1: missing. IsNotFound is the materializer's trigger to fetch + // the ancestor chain — it must NOT read as tombstoned. + _, _, err := repo.ResolveStrongRef(ctx, testAPObjectID) + assert.True(t, errors.IsNotFound(err), "unmaterialized parent must be IsNotFound, got %v", err) + assert.False(t, errors.IsTombstoned(err), "missing must not read as tombstoned") + + // Branch 2: present. + stored, err := repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) + + atURI, cid, err := repo.ResolveStrongRef(ctx, testAPObjectID) + require.NoError(t, err) + assert.Equal(t, stored.ATURI, atURI) + assert.Equal(t, testCID, cid) + + // Branch 3: tombstoned. IsTombstoned tells the materializer to drop + // the subtree; it must NOT read as not-found, which would trigger a + // consent-violating re-fetch of deleted content. + require.NoError(t, repo.SoftDelete(ctx, testAPObjectID)) + _, _, err = repo.ResolveStrongRef(ctx, testAPObjectID) + assert.True(t, errors.IsTombstoned(err), "soft-deleted object must be IsTombstoned, got %v", err) + assert.False(t, errors.IsNotFound(err), "tombstoned must not read as not-found") +} + +func TestAPObjects_SoftDeleteAndRevive(t *testing.T) { + repo := NewAPObjects(testDB(t)) + ctx := context.Background() + + _, err := repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) + + require.NoError(t, repo.SoftDelete(ctx, testAPObjectID)) + + // Gets still return the tombstoned row so callers can see the state. + deleted, err := repo.GetByAPID(ctx, testAPObjectID) + require.NoError(t, err) + assert.True(t, deleted.IsDeleted()) + + // Deleting again is an idempotent no-op that preserves the original + // tombstone time; deleting a missing object is a not-found error. + require.NoError(t, repo.SoftDelete(ctx, testAPObjectID)) + redeleted, err := repo.GetByAPID(ctx, testAPObjectID) + require.NoError(t, err) + require.NotNil(t, redeleted.DeletedAt) + assert.True(t, redeleted.DeletedAt.Equal(*deleted.DeletedAt), + "re-delete must not move the original tombstone time") + err = repo.SoftDelete(ctx, "https://lemmy.world/post/never-existed") + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) + + // Re-materialization revives the mapping. + revived, err := repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) + assert.False(t, revived.IsDeleted()) + + _, _, err = repo.ResolveStrongRef(ctx, testAPObjectID) + assert.NoError(t, err, "revived mapping must resolve again") +} + +// TestAPObjects_PutMapping_Validation is pure input validation: it never +// touches postgres, so it runs without TIDEPOOL_TEST_DATABASE_URL. +func TestAPObjects_PutMapping_Validation(t *testing.T) { + repo := NewAPObjects(nil) + ctx := context.Background() + + cases := []struct { + name string + mutate func(*APObjectMapping) + }{ + {"empty ap_id", func(m *APObjectMapping) { m.APID = "" }}, + {"empty ap_type", func(m *APObjectMapping) { m.APType = "" }}, + {"empty origin_instance", func(m *APObjectMapping) { m.OriginInstance = "" }}, + {"invalid origin", func(m *APObjectMapping) { m.Origin = "mastodon" }}, + {"empty cid", func(m *APObjectMapping) { m.CID = "" }}, + {"malformed cid", func(m *APObjectMapping) { m.CID = "not a cid!" }}, + {"cidv0", func(m *APObjectMapping) { m.CID = "QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR" }}, + {"invalid did", func(m *APObjectMapping) { m.DID = "not-a-did" }}, + {"invalid collection", func(m *APObjectMapping) { m.Collection = "not an nsid" }}, + {"invalid rkey", func(m *APObjectMapping) { m.RKey = "has spaces!" }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + mapping := testMapping() + testCase.mutate(&mapping) + _, err := repo.PutMapping(ctx, mapping) + assert.True(t, errors.IsValidation(err), "expected validation error, got %v", err) + }) + } +} + +func TestAPObjects_PutMapping_ATURICollisionIsConflict(t *testing.T) { + repo := NewAPObjects(testDB(t)) + ctx := context.Background() + + _, err := repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) + + // A different AP object claiming the same (did, collection, rkey) — + // and therefore the same at-uri — is a caller bug surfaced as a + // conflict, not silently absorbed. + collision := testMapping() + collision.APID = "https://lemmy.world/post/67890" + _, err = repo.PutMapping(ctx, collision) + assert.True(t, errors.IsAlreadyExists(err), "expected IsAlreadyExists, got %v", err) +} diff --git a/internal/store/bridged_actors.go b/internal/store/bridged_actors.go new file mode 100644 index 0000000..12e3525 --- /dev/null +++ b/internal/store/bridged_actors.go @@ -0,0 +1,209 @@ +package store + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "tidepool/internal/errors" +) + +type postgresBridgedActors struct { + db *sql.DB +} + +// NewBridgedActors creates the postgres-backed bridged_actors repository. +func NewBridgedActors(db *sql.DB) BridgedActors { + return &postgresBridgedActors{db: db} +} + +const bridgedActorColumns = ` + id, ap_actor_id, actor_type, did, COALESCE(handle, ''), + signing_key, consent_state, profile_synced_at, created_at` + +func (r *postgresBridgedActors) UpsertActor(ctx context.Context, actor BridgedActor) (*BridgedActor, error) { + if err := validateBridgedActor(&actor); err != nil { + return nil, err + } + + // On conflict: + // - handle and signing_key are sticky: an upsert built from AP data + // alone (profile refresh) carries neither, and must never clobber + // escrowed values with NULL. Non-empty new values do overwrite. + // - did, actor_type, consent_state, and created_at never change + // (identity is immutable once minted; consent only moves through + // SetConsentState). + // - the DO UPDATE's WHERE freezes tombstoned rows entirely and + // refuses identity drift; both surface as "no row returned" and + // are disambiguated by re-reading the stored row below. + query := ` + INSERT INTO bridged_actors ( + ap_actor_id, actor_type, did, handle, + signing_key, consent_state + ) VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6) + ON CONFLICT (ap_actor_id) DO UPDATE SET + handle = COALESCE(NULLIF(EXCLUDED.handle, ''), bridged_actors.handle), + signing_key = COALESCE(EXCLUDED.signing_key, bridged_actors.signing_key) + WHERE bridged_actors.consent_state <> 'deleted' + AND bridged_actors.did = EXCLUDED.did + AND bridged_actors.actor_type = EXCLUDED.actor_type + RETURNING` + bridgedActorColumns + + row := r.db.QueryRowContext(ctx, query, + actor.APActorID, string(actor.ActorType), actor.DID, actor.Handle, + actor.SigningKeyEncrypted, string(actor.ConsentState), + ) + stored, err := scanBridgedActor(row) + if stderrors.Is(err, sql.ErrNoRows) { + // The DO UPDATE's WHERE excluded the existing row: either the + // caller's identity fields diverge from the stored ones (conflict) + // or the actor is tombstoned (return the frozen row unchanged). + existing, getErr := r.GetByAPActorID(ctx, actor.APActorID) + if getErr != nil { + return nil, fmt.Errorf("upsert bridged_actor %q: recheck after excluded update: %w", actor.APActorID, getErr) + } + if existing.DID != actor.DID { + return nil, errors.NewConflictError("bridged_actor", "did", actor.DID) + } + if existing.ActorType != actor.ActorType { + return nil, errors.NewConflictError("bridged_actor", "actor_type", string(actor.ActorType)) + } + return existing, nil + } + if err != nil { + if constraint, ok := uniqueViolation(err); ok { + switch constraint { + case "bridged_actors_did_key": + return nil, errors.NewConflictError("bridged_actor", "did", actor.DID) + case "bridged_actors_handle_key": + return nil, errors.NewConflictError("bridged_actor", "handle", actor.Handle) + } + } + return nil, fmt.Errorf("upsert bridged_actor %q: %w", actor.APActorID, err) + } + return stored, nil +} + +func (r *postgresBridgedActors) GetByAPActorID(ctx context.Context, apActorID string) (*BridgedActor, error) { + query := `SELECT` + bridgedActorColumns + ` FROM bridged_actors WHERE ap_actor_id = $1` + actor, err := scanBridgedActor(r.db.QueryRowContext(ctx, query, apActorID)) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("bridged_actor", apActorID) + } + return nil, fmt.Errorf("get bridged_actor by ap_actor_id %q: %w", apActorID, err) + } + return actor, nil +} + +func (r *postgresBridgedActors) GetByDID(ctx context.Context, did string) (*BridgedActor, error) { + query := `SELECT` + bridgedActorColumns + ` FROM bridged_actors WHERE did = $1` + actor, err := scanBridgedActor(r.db.QueryRowContext(ctx, query, did)) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("bridged_actor", did) + } + return nil, fmt.Errorf("get bridged_actor by did %q: %w", did, err) + } + return actor, nil +} + +func (r *postgresBridgedActors) SetConsentState(ctx context.Context, apActorID string, state ConsentState) error { + if !state.Valid() { + return errors.NewValidationError("consent_state", fmt.Sprintf("unknown state %q", state)) + } + + // Deleted is terminal: the update's WHERE refuses to move an actor out + // of it (setting deleted on an already-deleted actor stays an + // idempotent no-op success). One statement reads the current state and + // attempts the update atomically, so "not found" and "terminal state" + // are distinguished without a racy follow-up query. + query := ` + WITH current AS ( + SELECT consent_state FROM bridged_actors WHERE ap_actor_id = $1 + ), attempted AS ( + UPDATE bridged_actors + SET consent_state = $2 + WHERE ap_actor_id = $1 AND (consent_state <> 'deleted' OR $2 = 'deleted') + RETURNING 1 + ) + SELECT (SELECT consent_state FROM current), + EXISTS (SELECT 1 FROM attempted)` + + var currentState sql.NullString + var updated bool + err := r.db.QueryRowContext(ctx, query, apActorID, string(state)).Scan(¤tState, &updated) + if err != nil { + return fmt.Errorf("set consent_state for %q: %w", apActorID, err) + } + if !currentState.Valid { + return errors.NewNotFoundError("bridged_actor", apActorID) + } + if !updated { + return errors.NewValidationError("consent_state", + fmt.Sprintf("cannot transition out of terminal state %q", currentState.String)) + } + return nil +} + +func (r *postgresBridgedActors) MarkProfileSynced(ctx context.Context, apActorID string, syncedAt time.Time) error { + query := `UPDATE bridged_actors SET profile_synced_at = $2 WHERE ap_actor_id = $1` + result, err := r.db.ExecContext(ctx, query, apActorID, syncedAt) + if err != nil { + return fmt.Errorf("mark profile synced for %q: %w", apActorID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("mark profile synced for %q: rows affected: %w", apActorID, err) + } + if affected == 0 { + return errors.NewNotFoundError("bridged_actor", apActorID) + } + return nil +} + +func validateBridgedActor(actor *BridgedActor) error { + if actor.APActorID == "" { + return errors.NewValidationError("ap_actor_id", "must not be empty") + } + if !actor.ActorType.Valid() { + return errors.NewValidationError("actor_type", + fmt.Sprintf("must be %q or %q, got %q", ActorTypePerson, ActorTypeGroup, actor.ActorType)) + } + if _, err := syntax.ParseDID(actor.DID); err != nil { + return errors.NewValidationError("did", err.Error()) + } + if actor.Handle != "" { + if _, err := syntax.ParseHandle(actor.Handle); err != nil { + return errors.NewValidationError("handle", err.Error()) + } + } + // Consent must be stated explicitly: the zero value failing open to + // "consented" would be a consent bug, so "" is rejected outright. + if !actor.ConsentState.Valid() { + return errors.NewValidationError("consent_state", + fmt.Sprintf("must be stated explicitly (%q, %q, or %q), got %q", + ConsentStateOK, ConsentStateNoBridge, ConsentStateDeleted, actor.ConsentState)) + } + return nil +} + +func scanBridgedActor(row rowScanner) (*BridgedActor, error) { + var actor BridgedActor + var actorType, consentState string + err := row.Scan( + &actor.ID, &actor.APActorID, &actorType, &actor.DID, &actor.Handle, + &actor.SigningKeyEncrypted, &consentState, + &actor.ProfileSyncedAt, &actor.CreatedAt, + ) + if err != nil { + return nil, err + } + actor.ActorType = ActorType(actorType) + actor.ConsentState = ConsentState(consentState) + return &actor, nil +} diff --git a/internal/store/bridged_actors_test.go b/internal/store/bridged_actors_test.go new file mode 100644 index 0000000..fb781b7 --- /dev/null +++ b/internal/store/bridged_actors_test.go @@ -0,0 +1,283 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" +) + +func TestBridgedActors_UpsertIsIdempotent(t *testing.T) { + database := testDB(t) + repo := NewBridgedActors(database) + ctx := context.Background() + + first, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + assert.Equal(t, ConsentStateOK, first.ConsentState) + assert.NotZero(t, first.CreatedAt) + + // Re-upserting with new non-empty values updates the mutable fields + // and nothing else. + updated := testActor() + updated.Handle = "alice-renamed.lemmy-world.tidepool.example" + updated.SigningKeyEncrypted = []byte("rotated-key-bytes") + second, err := repo.UpsertActor(ctx, updated) + require.NoError(t, err) + + assert.Equal(t, first.ID, second.ID, "upsert must reuse the existing row") + assert.Equal(t, "alice-renamed.lemmy-world.tidepool.example", second.Handle) + assert.Equal(t, []byte("rotated-key-bytes"), second.SigningKeyEncrypted) + assert.Equal(t, first.DID, second.DID) + assert.Equal(t, first.CreatedAt, second.CreatedAt) + + var total int + require.NoError(t, database.QueryRow(`SELECT COUNT(*) FROM bridged_actors`).Scan(&total)) + assert.Equal(t, 1, total) +} + +func TestBridgedActors_UpsertPreservesHandleAndKeyWhenOmitted(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + first, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + require.NotEmpty(t, first.Handle) + require.NotEmpty(t, first.SigningKeyEncrypted) + + // A profile refresh built purely from AP data carries no handle and no + // key material; upserting it must not clobber the escrowed values. + refresh := testActor() + refresh.Handle = "" + refresh.SigningKeyEncrypted = nil + refreshed, err := repo.UpsertActor(ctx, refresh) + require.NoError(t, err) + + assert.Equal(t, first.Handle, refreshed.Handle, "empty handle must not clobber the stored handle") + assert.Equal(t, first.SigningKeyEncrypted, refreshed.SigningKeyEncrypted, + "nil signing key must not clobber the escrowed key") +} + +func TestBridgedActors_UpsertOnDeletedActorIsFrozen(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + original, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + require.NoError(t, repo.SetConsentState(ctx, testAPActorID, ConsentStateDeleted)) + + // A tombstoned actor is frozen: the upsert modifies nothing — not even + // the normally-mutable handle and key — and returns the stored row. + attempt := testActor() + attempt.Handle = "necromancer.lemmy-world.tidepool.example" + attempt.SigningKeyEncrypted = []byte("fresh-key-bytes") + frozen, err := repo.UpsertActor(ctx, attempt) + require.NoError(t, err) + + assert.Equal(t, original.ID, frozen.ID) + assert.Equal(t, ConsentStateDeleted, frozen.ConsentState, "consent must stay deleted") + assert.Equal(t, original.Handle, frozen.Handle, "handle must stay frozen on a deleted actor") + assert.Equal(t, original.SigningKeyEncrypted, frozen.SigningKeyEncrypted, + "signing key must stay frozen on a deleted actor") + + stored, err := repo.GetByAPActorID(ctx, testAPActorID) + require.NoError(t, err) + assert.Equal(t, ConsentStateDeleted, stored.ConsentState) + assert.Equal(t, original.Handle, stored.Handle) + assert.Equal(t, original.SigningKeyEncrypted, stored.SigningKeyEncrypted) +} + +func TestBridgedActors_UpsertPreservesConsentState(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + _, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + require.NoError(t, repo.SetConsentState(ctx, testAPActorID, ConsentStateNoBridge)) + + // A profile refresh (upsert) must not silently flip consent back, + // even though the incoming actor states ConsentStateOK. + refreshed, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + assert.Equal(t, ConsentStateNoBridge, refreshed.ConsentState) +} + +func TestBridgedActors_UpsertRejectsIdentityDrift(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + original, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + + // Same AP actor id arriving with a different DID must be a conflict, + // not a silent success that keeps the old DID. + drifted := testActor() + drifted.DID = testSecondDID + _, err = repo.UpsertActor(ctx, drifted) + assert.True(t, errors.IsAlreadyExists(err), "changed DID must conflict, got %v", err) + + // Same for a changed actor type. + retyped := testActor() + retyped.ActorType = ActorTypeGroup + _, err = repo.UpsertActor(ctx, retyped) + assert.True(t, errors.IsAlreadyExists(err), "changed actor_type must conflict, got %v", err) + + // The stored row is untouched by the rejected upserts. + stored, err := repo.GetByAPActorID(ctx, testAPActorID) + require.NoError(t, err) + assert.Equal(t, original.DID, stored.DID) + assert.Equal(t, original.ActorType, stored.ActorType) +} + +func TestBridgedActors_Get(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + stored, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + + byAPID, err := repo.GetByAPActorID(ctx, testAPActorID) + require.NoError(t, err) + assert.Equal(t, stored.ID, byAPID.ID) + + byDID, err := repo.GetByDID(ctx, testDID) + require.NoError(t, err) + assert.Equal(t, stored.ID, byDID.ID) + + _, err = repo.GetByAPActorID(ctx, "https://lemmy.world/u/nobody") + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) + _, err = repo.GetByDID(ctx, testSecondDID) + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} + +func TestBridgedActors_ConsentStateTransitions(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + _, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + + // ok -> nobridge (actor added #nobridge to their bio) and back + // (they removed it). + require.NoError(t, repo.SetConsentState(ctx, testAPActorID, ConsentStateNoBridge)) + actor, err := repo.GetByAPActorID(ctx, testAPActorID) + require.NoError(t, err) + assert.Equal(t, ConsentStateNoBridge, actor.ConsentState) + + require.NoError(t, repo.SetConsentState(ctx, testAPActorID, ConsentStateOK)) + actor, err = repo.GetByAPActorID(ctx, testAPActorID) + require.NoError(t, err) + assert.Equal(t, ConsentStateOK, actor.ConsentState) + + // Delete(Actor) tombstones; deleting twice is idempotent. + require.NoError(t, repo.SetConsentState(ctx, testAPActorID, ConsentStateDeleted)) + require.NoError(t, repo.SetConsentState(ctx, testAPActorID, ConsentStateDeleted)) + + // Deleted is terminal. + err = repo.SetConsentState(ctx, testAPActorID, ConsentStateOK) + assert.True(t, errors.IsValidation(err), "leaving deleted must fail validation, got %v", err) + err = repo.SetConsentState(ctx, testAPActorID, ConsentStateNoBridge) + assert.True(t, errors.IsValidation(err), "leaving deleted must fail validation, got %v", err) + + actor, err = repo.GetByAPActorID(ctx, testAPActorID) + require.NoError(t, err) + assert.Equal(t, ConsentStateDeleted, actor.ConsentState) + + // Unknown states and unknown actors are rejected. + err = repo.SetConsentState(ctx, testAPActorID, ConsentState("banished")) + assert.True(t, errors.IsValidation(err), "expected validation error, got %v", err) + err = repo.SetConsentState(ctx, "https://lemmy.world/u/nobody", ConsentStateOK) + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} + +func TestBridgedActors_MarkProfileSynced(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + stored, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + assert.Nil(t, stored.ProfileSyncedAt) + + syncedAt := time.Date(2026, 6, 1, 8, 30, 0, 0, time.UTC) + require.NoError(t, repo.MarkProfileSynced(ctx, testAPActorID, syncedAt)) + + actor, err := repo.GetByAPActorID(ctx, testAPActorID) + require.NoError(t, err) + require.NotNil(t, actor.ProfileSyncedAt) + assert.True(t, actor.ProfileSyncedAt.Equal(syncedAt)) + + err = repo.MarkProfileSynced(ctx, "https://lemmy.world/u/nobody", syncedAt) + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} + +// TestBridgedActors_UpsertValidation is pure input validation: it never +// touches postgres, so it runs without TIDEPOOL_TEST_DATABASE_URL. +func TestBridgedActors_UpsertValidation(t *testing.T) { + repo := NewBridgedActors(nil) + ctx := context.Background() + + cases := []struct { + name string + mutate func(*BridgedActor) + }{ + {"empty ap_actor_id", func(a *BridgedActor) { a.APActorID = "" }}, + {"invalid actor_type", func(a *BridgedActor) { a.ActorType = "service" }}, + {"invalid did", func(a *BridgedActor) { a.DID = "not-a-did" }}, + {"invalid handle", func(a *BridgedActor) { a.Handle = "no spaces allowed" }}, + {"invalid consent_state", func(a *BridgedActor) { a.ConsentState = "banished" }}, + // Consent must never default: the zero value failing open to + // "consented" would be a consent bug. + {"empty consent_state", func(a *BridgedActor) { a.ConsentState = "" }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + actor := testActor() + testCase.mutate(&actor) + _, err := repo.UpsertActor(ctx, actor) + assert.True(t, errors.IsValidation(err), "expected validation error, got %v", err) + }) + } +} + +func TestBridgedActors_DIDCollisionIsConflict(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + _, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + + // A different AP actor must not claim the same DID. + collision := testActor() + collision.APActorID = "https://lemmy.world/u/mallory" + collision.Handle = "mallory.lemmy-world.tidepool.example" + _, err = repo.UpsertActor(ctx, collision) + assert.True(t, errors.IsAlreadyExists(err), "expected IsAlreadyExists, got %v", err) +} + +func TestBridgedActors_HandleCollisionIsConflict(t *testing.T) { + repo := NewBridgedActors(testDB(t)) + ctx := context.Background() + + _, err := repo.UpsertActor(ctx, testActor()) + require.NoError(t, err) + + // atproto handles are 1:1 with DIDs: a different actor (different AP + // id, different DID) must not claim the same handle. + collision := testActor() + collision.APActorID = "https://lemmy.world/u/mallory" + collision.DID = testSecondDID + _, err = repo.UpsertActor(ctx, collision) + assert.True(t, errors.IsAlreadyExists(err), "expected IsAlreadyExists, got %v", err) + + // Unassigned handles (NULL) may repeat freely. + noHandle := testActor() + noHandle.APActorID = "https://lemmy.world/u/newcomer" + noHandle.DID = testSecondDID + noHandle.Handle = "" + _, err = repo.UpsertActor(ctx, noHandle) + assert.NoError(t, err, "actors without handles must not collide") +} diff --git a/internal/store/communities.go b/internal/store/communities.go new file mode 100644 index 0000000..43d7064 --- /dev/null +++ b/internal/store/communities.go @@ -0,0 +1,217 @@ +package store + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "tidepool/internal/errors" +) + +type postgresCommunities struct { + db *sql.DB +} + +// NewCommunities creates the postgres-backed communities repository. +func NewCommunities(db *sql.DB) Communities { + return &postgresCommunities{db: db} +} + +const communityColumns = ` + id, ap_group_id, did, preferred_username, instance, + follow_state, followed_at, last_backfill_at, created_at` + +func (r *postgresCommunities) UpsertCommunity(ctx context.Context, community Community) (*Community, error) { + if err := validateCommunity(&community); err != nil { + return nil, err + } + + // On conflict only preferred_username updates (display renames happen + // upstream); did, instance, follow state, and timestamps are preserved. + // The DO UPDATE's WHERE refuses identity drift (a changed DID or + // instance for a known ap_group_id): the excluded row surfaces as "no + // row returned" and is reported as a conflict below. + query := ` + INSERT INTO communities ( + ap_group_id, did, preferred_username, instance, follow_state + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (ap_group_id) DO UPDATE SET + preferred_username = EXCLUDED.preferred_username + WHERE communities.did = EXCLUDED.did + AND communities.instance = EXCLUDED.instance + RETURNING` + communityColumns + + followState := community.FollowState + if followState == "" { + followState = FollowStateNone + } + + row := r.db.QueryRowContext(ctx, query, + community.APGroupID, community.DID, community.PreferredUsername, + community.Instance, string(followState), + ) + stored, err := scanCommunity(row) + if stderrors.Is(err, sql.ErrNoRows) { + // The DO UPDATE's WHERE excluded the existing row: the caller's + // identity fields diverge from the stored ones. + existing, getErr := r.GetByAPGroupID(ctx, community.APGroupID) + if getErr != nil { + return nil, fmt.Errorf("upsert community %q: recheck after excluded update: %w", community.APGroupID, getErr) + } + if existing.DID != community.DID { + return nil, errors.NewConflictError("community", "did", community.DID) + } + return nil, errors.NewConflictError("community", "instance", community.Instance) + } + if err != nil { + // ap_group_id conflicts are absorbed by the upsert, so the only + // expected unique violation is the did constraint. + if constraint, ok := uniqueViolation(err); ok && constraint == "communities_did_key" { + return nil, errors.NewConflictError("community", "did", community.DID) + } + return nil, fmt.Errorf("upsert community %q: %w", community.APGroupID, err) + } + return stored, nil +} + +func (r *postgresCommunities) GetByAPGroupID(ctx context.Context, apGroupID string) (*Community, error) { + query := `SELECT` + communityColumns + ` FROM communities WHERE ap_group_id = $1` + community, err := scanCommunity(r.db.QueryRowContext(ctx, query, apGroupID)) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("community", apGroupID) + } + return nil, fmt.Errorf("get community by ap_group_id %q: %w", apGroupID, err) + } + return community, nil +} + +func (r *postgresCommunities) GetByDID(ctx context.Context, did string) (*Community, error) { + query := `SELECT` + communityColumns + ` FROM communities WHERE did = $1` + community, err := scanCommunity(r.db.QueryRowContext(ctx, query, did)) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("community", did) + } + return nil, fmt.Errorf("get community by did %q: %w", did, err) + } + return community, nil +} + +func (r *postgresCommunities) SetFollowState(ctx context.Context, apGroupID string, state FollowState) error { + if !state.Valid() { + return errors.NewValidationError("follow_state", fmt.Sprintf("unknown state %q", state)) + } + + // followed_at stamps only on the transition INTO accepted — AP happily + // redelivers Accept, and a re-accept must not re-stamp the original + // time. none clears it; pending leaves it alone. + query := ` + UPDATE communities + SET followed_at = CASE + WHEN $2 = 'accepted' AND follow_state <> 'accepted' THEN CURRENT_TIMESTAMP + WHEN $2 = 'none' THEN NULL + ELSE followed_at + END, + follow_state = $2 + WHERE ap_group_id = $1` + + result, err := r.db.ExecContext(ctx, query, apGroupID, string(state)) + if err != nil { + return fmt.Errorf("set follow_state for %q: %w", apGroupID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("set follow_state for %q: rows affected: %w", apGroupID, err) + } + if affected == 0 { + return errors.NewNotFoundError("community", apGroupID) + } + return nil +} + +func (r *postgresCommunities) SetLastBackfill(ctx context.Context, apGroupID string, backfilledAt time.Time) error { + query := `UPDATE communities SET last_backfill_at = $2 WHERE ap_group_id = $1` + result, err := r.db.ExecContext(ctx, query, apGroupID, backfilledAt) + if err != nil { + return fmt.Errorf("set last_backfill_at for %q: %w", apGroupID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("set last_backfill_at for %q: rows affected: %w", apGroupID, err) + } + if affected == 0 { + return errors.NewNotFoundError("community", apGroupID) + } + return nil +} + +func (r *postgresCommunities) ListByFollowState(ctx context.Context, state FollowState) ([]*Community, error) { + if !state.Valid() { + return nil, errors.NewValidationError("follow_state", fmt.Sprintf("unknown state %q", state)) + } + + query := `SELECT` + communityColumns + ` + FROM communities WHERE follow_state = $1 ORDER BY created_at, id` + + rows, err := r.db.QueryContext(ctx, query, string(state)) + if err != nil { + return nil, fmt.Errorf("list communities by follow_state %q: %w", state, err) + } + defer func() { _ = rows.Close() }() + + var communities []*Community + for rows.Next() { + community, err := scanCommunity(rows) + if err != nil { + return nil, fmt.Errorf("list communities by follow_state %q: scan: %w", state, err) + } + communities = append(communities, community) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list communities by follow_state %q: rows: %w", state, err) + } + return communities, nil +} + +func validateCommunity(community *Community) error { + if community.APGroupID == "" { + return errors.NewValidationError("ap_group_id", "must not be empty") + } + if _, err := syntax.ParseDID(community.DID); err != nil { + return errors.NewValidationError("did", err.Error()) + } + if community.PreferredUsername == "" { + return errors.NewValidationError("preferred_username", "must not be empty") + } + if community.Instance == "" { + return errors.NewValidationError("instance", "must not be empty") + } + // Unlike consent, follow state has a safe zero value: "" defaults to + // none in UpsertCommunity. + if community.FollowState != "" && !community.FollowState.Valid() { + return errors.NewValidationError("follow_state", + fmt.Sprintf("unknown state %q", community.FollowState)) + } + return nil +} + +func scanCommunity(row rowScanner) (*Community, error) { + var community Community + var followState string + err := row.Scan( + &community.ID, &community.APGroupID, &community.DID, + &community.PreferredUsername, &community.Instance, + &followState, &community.FollowedAt, &community.LastBackfillAt, + &community.CreatedAt, + ) + if err != nil { + return nil, err + } + community.FollowState = FollowState(followState) + return &community, nil +} diff --git a/internal/store/communities_test.go b/internal/store/communities_test.go new file mode 100644 index 0000000..b91ff12 --- /dev/null +++ b/internal/store/communities_test.go @@ -0,0 +1,236 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" +) + +func TestCommunities_UpsertIsIdempotent(t *testing.T) { + database := testDB(t) + repo := NewCommunities(database) + ctx := context.Background() + + first, err := repo.UpsertCommunity(ctx, testCommunity()) + require.NoError(t, err) + assert.Equal(t, FollowStateNone, first.FollowState, "follow state defaults to none") + + renamed := testCommunity() + renamed.PreferredUsername = "tech" + second, err := repo.UpsertCommunity(ctx, renamed) + require.NoError(t, err) + + assert.Equal(t, first.ID, second.ID, "upsert must reuse the existing row") + assert.Equal(t, "tech", second.PreferredUsername) + assert.Equal(t, first.DID, second.DID) + + var total int + require.NoError(t, database.QueryRow(`SELECT COUNT(*) FROM communities`).Scan(&total)) + assert.Equal(t, 1, total) +} + +func TestCommunities_UpsertRejectsIdentityDrift(t *testing.T) { + repo := NewCommunities(testDB(t)) + ctx := context.Background() + + original, err := repo.UpsertCommunity(ctx, testCommunity()) + require.NoError(t, err) + + // Same AP group id arriving with a different DID must be a conflict, + // not a silent success that keeps the old DID. + drifted := testCommunity() + drifted.DID = testSecondDID + _, err = repo.UpsertCommunity(ctx, drifted) + assert.True(t, errors.IsAlreadyExists(err), "changed DID must conflict, got %v", err) + + // Same for a changed instance. + moved := testCommunity() + moved.Instance = "lemmy.ml" + _, err = repo.UpsertCommunity(ctx, moved) + assert.True(t, errors.IsAlreadyExists(err), "changed instance must conflict, got %v", err) + + // The stored row is untouched by the rejected upserts. + stored, err := repo.GetByAPGroupID(ctx, testAPGroupID) + require.NoError(t, err) + assert.Equal(t, original.DID, stored.DID) + assert.Equal(t, original.Instance, stored.Instance) +} + +func TestCommunities_UpsertPreservesFollowState(t *testing.T) { + repo := NewCommunities(testDB(t)) + ctx := context.Background() + + _, err := repo.UpsertCommunity(ctx, testCommunity()) + require.NoError(t, err) + require.NoError(t, repo.SetFollowState(ctx, testAPGroupID, FollowStateAccepted)) + + refreshed, err := repo.UpsertCommunity(ctx, testCommunity()) + require.NoError(t, err) + assert.Equal(t, FollowStateAccepted, refreshed.FollowState) + assert.NotNil(t, refreshed.FollowedAt) +} + +func TestCommunities_Get(t *testing.T) { + repo := NewCommunities(testDB(t)) + ctx := context.Background() + + stored, err := repo.UpsertCommunity(ctx, testCommunity()) + require.NoError(t, err) + + byGroupID, err := repo.GetByAPGroupID(ctx, testAPGroupID) + require.NoError(t, err) + assert.Equal(t, stored.ID, byGroupID.ID) + + byDID, err := repo.GetByDID(ctx, testDID) + require.NoError(t, err) + assert.Equal(t, stored.ID, byDID.ID) + + _, err = repo.GetByAPGroupID(ctx, "https://lemmy.world/c/missing") + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) + _, err = repo.GetByDID(ctx, testSecondDID) + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} + +func TestCommunities_FollowStateLifecycle(t *testing.T) { + repo := NewCommunities(testDB(t)) + ctx := context.Background() + + _, err := repo.UpsertCommunity(ctx, testCommunity()) + require.NoError(t, err) + + // none -> pending: Follow sent, awaiting Accept. No followed_at yet. + require.NoError(t, repo.SetFollowState(ctx, testAPGroupID, FollowStatePending)) + community, err := repo.GetByAPGroupID(ctx, testAPGroupID) + require.NoError(t, err) + assert.Equal(t, FollowStatePending, community.FollowState) + assert.Nil(t, community.FollowedAt) + + // pending -> accepted stamps followed_at. + require.NoError(t, repo.SetFollowState(ctx, testAPGroupID, FollowStateAccepted)) + community, err = repo.GetByAPGroupID(ctx, testAPGroupID) + require.NoError(t, err) + assert.Equal(t, FollowStateAccepted, community.FollowState) + assert.NotNil(t, community.FollowedAt) + + // AP redelivers Accept: re-setting accepted must NOT re-stamp the + // original followed_at. + firstFollowedAt := community.FollowedAt + require.NoError(t, repo.SetFollowState(ctx, testAPGroupID, FollowStateAccepted)) + community, err = repo.GetByAPGroupID(ctx, testAPGroupID) + require.NoError(t, err) + require.NotNil(t, community.FollowedAt) + assert.True(t, community.FollowedAt.Equal(*firstFollowedAt), + "double-accept must preserve the original followed_at") + + // accepted -> none (unfollow) clears followed_at. + require.NoError(t, repo.SetFollowState(ctx, testAPGroupID, FollowStateNone)) + community, err = repo.GetByAPGroupID(ctx, testAPGroupID) + require.NoError(t, err) + assert.Equal(t, FollowStateNone, community.FollowState) + assert.Nil(t, community.FollowedAt) + + // Unknown states and unknown groups are rejected. + err = repo.SetFollowState(ctx, testAPGroupID, FollowState("blocked")) + assert.True(t, errors.IsValidation(err), "expected validation error, got %v", err) + err = repo.SetFollowState(ctx, "https://lemmy.world/c/missing", FollowStatePending) + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} + +func TestCommunities_SetLastBackfill(t *testing.T) { + repo := NewCommunities(testDB(t)) + ctx := context.Background() + + _, err := repo.UpsertCommunity(ctx, testCommunity()) + require.NoError(t, err) + + backfilledAt := time.Date(2026, 6, 15, 3, 0, 0, 0, time.UTC) + require.NoError(t, repo.SetLastBackfill(ctx, testAPGroupID, backfilledAt)) + + community, err := repo.GetByAPGroupID(ctx, testAPGroupID) + require.NoError(t, err) + require.NotNil(t, community.LastBackfillAt) + assert.True(t, community.LastBackfillAt.Equal(backfilledAt)) + + err = repo.SetLastBackfill(ctx, "https://lemmy.world/c/missing", backfilledAt) + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} + +func TestCommunities_ListByFollowState(t *testing.T) { + repo := NewCommunities(testDB(t)) + ctx := context.Background() + + first := testCommunity() + _, err := repo.UpsertCommunity(ctx, first) + require.NoError(t, err) + + second := Community{ + APGroupID: "https://lemmy.world/c/golang", + DID: testSecondDID, + PreferredUsername: "golang", + Instance: testInstance, + } + _, err = repo.UpsertCommunity(ctx, second) + require.NoError(t, err) + require.NoError(t, repo.SetFollowState(ctx, second.APGroupID, FollowStateAccepted)) + + pending, err := repo.ListByFollowState(ctx, FollowStatePending) + require.NoError(t, err) + assert.Empty(t, pending) + + none, err := repo.ListByFollowState(ctx, FollowStateNone) + require.NoError(t, err) + require.Len(t, none, 1) + assert.Equal(t, first.APGroupID, none[0].APGroupID) + + accepted, err := repo.ListByFollowState(ctx, FollowStateAccepted) + require.NoError(t, err) + require.Len(t, accepted, 1) + assert.Equal(t, second.APGroupID, accepted[0].APGroupID) + + _, err = repo.ListByFollowState(ctx, FollowState("blocked")) + assert.True(t, errors.IsValidation(err), "expected validation error, got %v", err) +} + +// TestCommunities_UpsertValidation is pure input validation: it never +// touches postgres, so it runs without TIDEPOOL_TEST_DATABASE_URL. +func TestCommunities_UpsertValidation(t *testing.T) { + repo := NewCommunities(nil) + ctx := context.Background() + + cases := []struct { + name string + mutate func(*Community) + }{ + {"empty ap_group_id", func(c *Community) { c.APGroupID = "" }}, + {"invalid did", func(c *Community) { c.DID = "not-a-did" }}, + {"empty preferred_username", func(c *Community) { c.PreferredUsername = "" }}, + {"empty instance", func(c *Community) { c.Instance = "" }}, + {"invalid follow_state", func(c *Community) { c.FollowState = "blocked" }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + community := testCommunity() + testCase.mutate(&community) + _, err := repo.UpsertCommunity(ctx, community) + assert.True(t, errors.IsValidation(err), "expected validation error, got %v", err) + }) + } +} + +func TestCommunities_DIDCollisionIsConflict(t *testing.T) { + repo := NewCommunities(testDB(t)) + ctx := context.Background() + + _, err := repo.UpsertCommunity(ctx, testCommunity()) + require.NoError(t, err) + + collision := testCommunity() + collision.APGroupID = "https://lemmy.world/c/imposter" + _, err = repo.UpsertCommunity(ctx, collision) + assert.True(t, errors.IsAlreadyExists(err), "expected IsAlreadyExists, got %v", err) +} diff --git a/internal/store/inbox_events.go b/internal/store/inbox_events.go new file mode 100644 index 0000000..aa160e1 --- /dev/null +++ b/internal/store/inbox_events.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + + "tidepool/internal/errors" +) + +type postgresInboxEvents struct { + db *sql.DB +} + +// NewInboxEvents creates the postgres-backed inbox_events repository. +func NewInboxEvents(db *sql.DB) InboxEvents { + return &postgresInboxEvents{db: db} +} + +func (r *postgresInboxEvents) RecordEvent(ctx context.Context, activityID, activityType string) (bool, error) { + if activityID == "" { + return false, errors.NewValidationError("activity_id", "must not be empty") + } + if activityType == "" { + return false, errors.NewValidationError("type", "must not be empty") + } + + query := ` + INSERT INTO inbox_events (activity_id, type) + VALUES ($1, $2) + ON CONFLICT (activity_id) DO NOTHING` + + result, err := r.db.ExecContext(ctx, query, activityID, activityType) + if err != nil { + return false, fmt.Errorf("record inbox event %q: %w", activityID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("record inbox event %q: rows affected: %w", activityID, err) + } + return affected == 1, nil +} + +func (r *postgresInboxEvents) MarkProcessed(ctx context.Context, activityID string) error { + query := ` + UPDATE inbox_events + SET processed_at = CURRENT_TIMESTAMP, error = NULL + WHERE activity_id = $1` + + result, err := r.db.ExecContext(ctx, query, activityID) + if err != nil { + return fmt.Errorf("mark inbox event %q processed: %w", activityID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("mark inbox event %q processed: rows affected: %w", activityID, err) + } + if affected == 0 { + return errors.NewNotFoundError("inbox_event", activityID) + } + return nil +} + +func (r *postgresInboxEvents) MarkFailed(ctx context.Context, activityID string, message string) error { + if message == "" { + return errors.NewValidationError("error", "must not be empty") + } + + // Only unprocessed events accept failures: a late failure report from + // a stale worker must not un-process an event a successful retry + // already completed. One statement distinguishes "row missing" + // (not found) from "row already processed" (no-op success). + query := ` + WITH updated AS ( + UPDATE inbox_events + SET error = $2 + WHERE activity_id = $1 AND processed_at IS NULL + RETURNING 1 + ) + SELECT EXISTS (SELECT 1 FROM inbox_events WHERE activity_id = $1)` + + var exists bool + if err := r.db.QueryRowContext(ctx, query, activityID, message).Scan(&exists); err != nil { + return fmt.Errorf("mark inbox event %q failed: %w", activityID, err) + } + if !exists { + return errors.NewNotFoundError("inbox_event", activityID) + } + return nil +} + +func (r *postgresInboxEvents) GetEvent(ctx context.Context, activityID string) (*InboxEvent, error) { + query := ` + SELECT id, activity_id, type, received_at, processed_at, COALESCE(error, '') + FROM inbox_events WHERE activity_id = $1` + + var event InboxEvent + err := r.db.QueryRowContext(ctx, query, activityID).Scan( + &event.ID, &event.ActivityID, &event.Type, + &event.ReceivedAt, &event.ProcessedAt, &event.Error, + ) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("inbox_event", activityID) + } + return nil, fmt.Errorf("get inbox event %q: %w", activityID, err) + } + return &event, nil +} diff --git a/internal/store/inbox_events_test.go b/internal/store/inbox_events_test.go new file mode 100644 index 0000000..4f12a54 --- /dev/null +++ b/internal/store/inbox_events_test.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" +) + +const testActivityID = "https://lemmy.world/activities/announce/abc-123" + +func TestInboxEvents_RecordEventDeduplicates(t *testing.T) { + repo := NewInboxEvents(testDB(t)) + ctx := context.Background() + + isNew, err := repo.RecordEvent(ctx, testActivityID, "Announce") + require.NoError(t, err) + assert.True(t, isNew, "first delivery must be new") + + // Redelivery of the same activity id is not an error, just not new. + isNew, err = repo.RecordEvent(ctx, testActivityID, "Announce") + require.NoError(t, err) + assert.False(t, isNew, "second delivery must be deduplicated") + + event, err := repo.GetEvent(ctx, testActivityID) + require.NoError(t, err) + assert.Equal(t, "Announce", event.Type) + assert.NotZero(t, event.ReceivedAt) + assert.Nil(t, event.ProcessedAt) + assert.Empty(t, event.Error) +} + +func TestInboxEvents_MarkProcessed(t *testing.T) { + repo := NewInboxEvents(testDB(t)) + ctx := context.Background() + + _, err := repo.RecordEvent(ctx, testActivityID, "Announce") + require.NoError(t, err) + + require.NoError(t, repo.MarkProcessed(ctx, testActivityID)) + + event, err := repo.GetEvent(ctx, testActivityID) + require.NoError(t, err) + assert.NotNil(t, event.ProcessedAt) + assert.Empty(t, event.Error) + + err = repo.MarkProcessed(ctx, "https://lemmy.world/activities/missing") + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} + +func TestInboxEvents_MarkFailedThenRecovers(t *testing.T) { + repo := NewInboxEvents(testDB(t)) + ctx := context.Background() + + _, err := repo.RecordEvent(ctx, testActivityID, "Create") + require.NoError(t, err) + + require.NoError(t, repo.MarkFailed(ctx, testActivityID, "parent object fetch timed out")) + + event, err := repo.GetEvent(ctx, testActivityID) + require.NoError(t, err) + assert.Nil(t, event.ProcessedAt, "failed events stay unprocessed for retry") + assert.Equal(t, "parent object fetch timed out", event.Error) + + // A later successful retry clears the error. + require.NoError(t, repo.MarkProcessed(ctx, testActivityID)) + event, err = repo.GetEvent(ctx, testActivityID) + require.NoError(t, err) + assert.NotNil(t, event.ProcessedAt) + assert.Empty(t, event.Error) + + err = repo.MarkFailed(ctx, "https://lemmy.world/activities/missing", "boom") + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} + +func TestInboxEvents_MarkFailedOnProcessedEventIsNoOp(t *testing.T) { + repo := NewInboxEvents(testDB(t)) + ctx := context.Background() + + _, err := repo.RecordEvent(ctx, testActivityID, "Create") + require.NoError(t, err) + require.NoError(t, repo.MarkProcessed(ctx, testActivityID)) + + // A late failure report from a stale worker must not un-process an + // event a successful retry already completed: no-op success. + require.NoError(t, repo.MarkFailed(ctx, testActivityID, "stale worker reporting late")) + + event, err := repo.GetEvent(ctx, testActivityID) + require.NoError(t, err) + assert.NotNil(t, event.ProcessedAt, "processed event must stay processed") + assert.Empty(t, event.Error, "late failure must not record an error on a processed event") +} + +// TestInboxEvents_Validation is pure input validation: it never touches +// postgres, so it runs without TIDEPOOL_TEST_DATABASE_URL. +func TestInboxEvents_Validation(t *testing.T) { + repo := NewInboxEvents(nil) + ctx := context.Background() + + _, err := repo.RecordEvent(ctx, "", "Announce") + assert.True(t, errors.IsValidation(err), "expected validation error, got %v", err) + + _, err = repo.RecordEvent(ctx, testActivityID, "") + assert.True(t, errors.IsValidation(err), "expected validation error, got %v", err) + + err = repo.MarkFailed(ctx, testActivityID, "") + assert.True(t, errors.IsValidation(err), "empty failure message must be rejected, got %v", err) +} + +func TestInboxEvents_GetEventMissing(t *testing.T) { + repo := NewInboxEvents(testDB(t)) + ctx := context.Background() + + _, err := repo.GetEvent(ctx, "https://lemmy.world/activities/missing") + assert.True(t, errors.IsNotFound(err), "expected IsNotFound, got %v", err) +} diff --git a/internal/store/interfaces.go b/internal/store/interfaces.go new file mode 100644 index 0000000..b1d2f61 --- /dev/null +++ b/internal/store/interfaces.go @@ -0,0 +1,144 @@ +// Package store is Tidepool's persistence spine: one repository per table, +// behind interfaces, using raw parameterized SQL over database/sql. Later +// tasks (identity, materializer, ingestion) consume these interfaces. +// +// Conventions shared by all repositories: +// - Writes are idempotent upserts (ON CONFLICT) keyed on the AP id. +// - Misses return errors satisfying errors.IsNotFound. +// - Deletes are soft (deleted_at) where the schema supports them. +package store + +import ( + "context" + "time" +) + +// APObjects maps AP object ids to the atproto records they materialized +// as, and back. Every materialization writes a mapping; every strongRef +// resolution reads one. +type APObjects interface { + // PutMapping idempotently upserts a mapping keyed on APID. It validates + // DID, Collection, RKey, and CID, derives ATURI from the first three, + // and returns the stored row. An empty Origin defaults to + // OriginFediverse (bridge-emitted writes must say OriginBridge + // explicitly). Re-putting an existing APID updates APType, Origin, DID, + // Collection, RKey, ATURI, CID, and PublishedAt, refreshes IndexedAt, + // and clears any soft delete (re-materialization revives the mapping). + PutMapping(ctx context.Context, mapping APObjectMapping) (*APObjectMapping, error) + + // GetByAPID returns the mapping for an AP object id, including + // soft-deleted rows (callers can check IsDeleted to detect tombstones). + GetByAPID(ctx context.Context, apID string) (*APObjectMapping, error) + + // GetByATURI returns the mapping for an at-uri, including soft-deleted + // rows. + GetByATURI(ctx context.Context, atURI string) (*APObjectMapping, error) + + // ResolveStrongRef resolves an AP object id to the (at-uri, cid) pair a + // strongRef needs. The two failure modes are deliberately distinct so + // the materializer can branch on them: + // - a missing object returns an error satisfying errors.IsNotFound — + // the trigger to fetch and materialize the ancestor chain; + // - a soft-deleted object returns an error satisfying + // errors.IsTombstoned (and NOT IsNotFound) — the subtree must be + // dropped, never re-fetched. + ResolveStrongRef(ctx context.Context, apID string) (atURI string, cid string, err error) + + // SoftDelete marks the mapping for an AP object id as deleted, in one + // atomic statement. Deleting an already-deleted mapping is a no-op that + // preserves the original tombstone time; a missing mapping is an error + // satisfying errors.IsNotFound. + SoftDelete(ctx context.Context, apID string) error +} + +// BridgedActors registers fediverse actors bridged into atproto and their +// escrowed signing keys and consent state. +type BridgedActors interface { + // UpsertActor idempotently upserts an actor keyed on APActorID and + // returns the stored row. ConsentState must be stated explicitly (the + // zero value is rejected; there is no fail-open default). On conflict: + // - Handle and SigningKeyEncrypted are sticky: an empty handle or nil + // key on the incoming actor (e.g. a profile refresh built purely + // from AP data) preserves the stored values; non-empty values + // overwrite them. + // - DID, ActorType, ConsentState, and CreatedAt never change. + // Identity is immutable once minted: an upsert whose DID or + // ActorType diverges from the stored row returns an error + // satisfying errors.IsAlreadyExists. + // - Tombstoned actors are frozen: upserting an actor whose stored + // ConsentState is ConsentStateDeleted modifies nothing and returns + // the stored row as-is (consent stays deleted). + UpsertActor(ctx context.Context, actor BridgedActor) (*BridgedActor, error) + + // GetByAPActorID returns the actor for an AP actor id. + GetByAPActorID(ctx context.Context, apActorID string) (*BridgedActor, error) + + // GetByDID returns the actor for a bridged DID. + GetByDID(ctx context.Context, did string) (*BridgedActor, error) + + // SetConsentState transitions the actor's consent state. Deleted is + // terminal: transitioning away from ConsentStateDeleted returns an + // error satisfying errors.IsValidation (re-tombstoning an already + // deleted actor stays a no-op success). + SetConsentState(ctx context.Context, apActorID string, state ConsentState) error + + // MarkProfileSynced records when the actor's profile record was last + // (re)materialized. + MarkProfileSynced(ctx context.Context, apActorID string, syncedAt time.Time) error +} + +// Communities tracks the AP groups the bridge subscribes to and their +// backfill progress. +type Communities interface { + // UpsertCommunity idempotently upserts a community keyed on APGroupID + // and returns the stored row. On conflict it updates PreferredUsername + // only; follow state and timestamps are preserved, and DID and Instance + // are immutable: an upsert whose DID or Instance diverges from the + // stored row returns an error satisfying errors.IsAlreadyExists. + UpsertCommunity(ctx context.Context, community Community) (*Community, error) + + // GetByAPGroupID returns the community for an AP group id. + GetByAPGroupID(ctx context.Context, apGroupID string) (*Community, error) + + // GetByDID returns the community for a bridged repo DID. + GetByDID(ctx context.Context, did string) (*Community, error) + + // SetFollowState transitions the Follow subscription state. Arbitrary + // transitions are legal — the states are driven by external AP + // activities (Accept, Reject, Undo) that arrive in whatever order the + // remote instance sends them. FollowedAt stamps only on the transition + // INTO accepted (a redelivered Accept does not re-stamp it) and clears + // on none. + SetFollowState(ctx context.Context, apGroupID string, state FollowState) error + + // SetLastBackfill records when an outbox backfill last completed. + SetLastBackfill(ctx context.Context, apGroupID string, backfilledAt time.Time) error + + // ListByFollowState returns all communities in the given follow state, + // ordered by creation time. + ListByFollowState(ctx context.Context, state FollowState) ([]*Community, error) +} + +// InboxEvents deduplicates inbound AP activities and records processing +// outcomes. The queue-consumption side (ListPending and friends) is +// deliberately deferred to task 06, which owns the processing loop. +type InboxEvents interface { + // RecordEvent inserts the activity if it has not been seen before. + // It returns isNew=false (and no error) when the activity id was + // already recorded — the caller should drop the duplicate delivery. + RecordEvent(ctx context.Context, activityID, activityType string) (isNew bool, err error) + + // MarkProcessed stamps the event as successfully processed and clears + // any recorded error. + MarkProcessed(ctx context.Context, activityID string) error + + // MarkFailed records a processing error on an unprocessed event, + // leaving it unprocessed so it can be retried or inspected. The message + // must be non-empty. Failing an already-processed event is a no-op + // success (a late failure report must not un-process a successful + // retry); a missing event is an error satisfying errors.IsNotFound. + MarkFailed(ctx context.Context, activityID string, message string) error + + // GetEvent returns the event for an activity id. + GetEvent(ctx context.Context, activityID string) (*InboxEvent, error) +} diff --git a/internal/store/migrations_test.go b/internal/store/migrations_test.go new file mode 100644 index 0000000..d7c79ef --- /dev/null +++ b/internal/store/migrations_test.go @@ -0,0 +1,68 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/db" +) + +// TestMigrations_UpDownUp proves every migration applies cleanly up AND +// down. It lives in this package (not internal/db) on purpose: `go test +// ./...` runs packages in parallel, and all tests sharing the postgres +// schema must stay in one package so they run sequentially. +func TestMigrations_UpDownUp(t *testing.T) { + database := testDB(t) // already migrated up by the harness + ctx := context.Background() + + require.NoError(t, db.MigrateDownTo(ctx, database, 0), "all down migrations must apply") + + var remaining int + err := database.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('ap_objects', 'bridged_actors', 'communities', 'inbox_events') + `).Scan(&remaining) + require.NoError(t, err) + assert.Zero(t, remaining, "down migrations must drop every Tidepool table") + + require.NoError(t, db.MigrateUp(ctx, database), "re-applying up migrations must succeed") + + // Leave the schema usable and prove it is: exercise a write. + repo := NewAPObjects(database) + _, err = repo.PutMapping(ctx, testMapping()) + require.NoError(t, err) +} + +// TestMigrations_UniqueConstraintNames pins the explicit constraint and +// index names the store layer's uniqueViolation mapping depends on. If a +// migration renames one, the 23505 → ConflictError mapping silently +// degrades to wrapped internal errors — this test makes that loud. +func TestMigrations_UniqueConstraintNames(t *testing.T) { + database := testDB(t) + ctx := context.Background() + + expected := []string{ + "ap_objects_ap_id_key", + "ap_objects_at_uri_key", + "bridged_actors_ap_actor_id_key", + "bridged_actors_did_key", + "bridged_actors_handle_key", // partial unique index, not a table constraint + "communities_ap_group_id_key", + "communities_did_key", + "inbox_events_activity_id_key", + } + for _, name := range expected { + var exists bool + err := database.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE schemaname = 'public' AND indexname = $1 + )`, name).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists, "unique constraint/index %q must exist with exactly this name", name) + } +} diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 0000000..4f3e4ba --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,144 @@ +package store + +import "time" + +// ActorType is the kind of AP actor a bridged identity represents. +type ActorType string + +const ( + ActorTypePerson ActorType = "person" + ActorTypeGroup ActorType = "group" +) + +// Valid reports whether the value is a known actor type. +func (t ActorType) Valid() bool { + switch t { + case ActorTypePerson, ActorTypeGroup: + return true + } + return false +} + +// ConsentState tracks whether an AP actor allows bridging. +type ConsentState string + +const ( + // ConsentStateOK means the actor has not opted out; bridging proceeds. + ConsentStateOK ConsentState = "ok" + // ConsentStateNoBridge means the actor opted out via #nobridge/#nobot; + // no new content is materialized while in this state. + ConsentStateNoBridge ConsentState = "nobridge" + // ConsentStateDeleted means the actor was deleted upstream + // (Delete(Actor)); the bridged repo is tombstoned. Terminal. + ConsentStateDeleted ConsentState = "deleted" +) + +// Valid reports whether the value is a known consent state. The zero value +// is deliberately invalid: consent must always be stated explicitly, never +// defaulted. +func (s ConsentState) Valid() bool { + switch s { + case ConsentStateOK, ConsentStateNoBridge, ConsentStateDeleted: + return true + } + return false +} + +// FollowState tracks the bridge's Follow subscription to an AP group. +type FollowState string + +const ( + FollowStateNone FollowState = "none" + FollowStatePending FollowState = "pending" + FollowStateAccepted FollowState = "accepted" +) + +// Valid reports whether the value is a known follow state. +func (s FollowState) Valid() bool { + switch s { + case FollowStateNone, FollowStatePending, FollowStateAccepted: + return true + } + return false +} + +// Origin discriminates which side of the bridge authored an AP object. +// Task 06's echo suppression drops inbound activities whose object maps to +// an OriginBridge row (our own writes reflected back by the fediverse). +type Origin string + +const ( + // OriginFediverse marks content authored on the fediverse side. + OriginFediverse Origin = "fediverse" + // OriginBridge marks content the bridge itself emitted. + OriginBridge Origin = "bridge" +) + +// Valid reports whether the value is a known origin. +func (o Origin) Valid() bool { + switch o { + case OriginFediverse, OriginBridge: + return true + } + return false +} + +// APObjectMapping is one row of the ap_objects spine: the bidirectional +// mapping between an AP object and the atproto record it materialized as. +type APObjectMapping struct { + ID int64 + APID string // canonical AP object id (URL) + APType string // AP type: Page, Note, Group, Person, ... + OriginInstance string // host the object originated from, e.g. lemmy.world + Origin Origin // which side authored the object; defaults to fediverse + DID string // repo the record was written into + Collection string // record NSID, e.g. social.coves.community.post + RKey string // deterministic TID rkey + ATURI string // at://did/collection/rkey (derived; set by PutMapping) + CID string // CID of the current record version + PublishedAt *time.Time // AP `published` time (may be absent upstream) + IndexedAt time.Time + DeletedAt *time.Time +} + +// IsDeleted reports whether the mapping has been soft-deleted. +func (m *APObjectMapping) IsDeleted() bool { return m.DeletedAt != nil } + +// BridgedActor is a fediverse actor (person or group) that Tidepool has +// minted an atproto identity for. +type BridgedActor struct { + ID int64 + APActorID string // canonical AP actor id (URL) + ActorType ActorType + DID string + Handle string // bridged handle; empty until assigned + SigningKeyEncrypted []byte // escrowed signing key; AES-GCM ciphertext from task 03 on + ConsentState ConsentState + ProfileSyncedAt *time.Time + CreatedAt time.Time +} + +// Community is an AP group the bridge follows (or is in the process of +// following), plus its backfill progress. +type Community struct { + ID int64 + APGroupID string // canonical AP Group actor id (URL) + DID string // the community's bridged repo DID + PreferredUsername string + Instance string // host, e.g. lemmy.world + FollowState FollowState + FollowedAt *time.Time + LastBackfillAt *time.Time + CreatedAt time.Time +} + +// InboxEvent is a received AP activity, recorded for dedupe and +// processing bookkeeping. +type InboxEvent struct { + ID int64 + ActivityID string // AP activity id (URL) — the dedupe key + Type string // AP activity type: Announce, Create, Like, ... + ReceivedAt time.Time + ProcessedAt *time.Time + Error string // last processing error; empty if none +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..5b3176d --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,112 @@ +package store + +import ( + "context" + "database/sql" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "tidepool/internal/db" +) + +// The store tests run against a real postgres database (Coves convention: +// real infrastructure, no mocks). They skip cleanly when +// TIDEPOOL_TEST_DATABASE_URL is unset; `make test` starts the postgres-test +// container and sets it. + +var ( + testDatabaseOnce sync.Once + testDatabase *sql.DB + testDatabaseErr error +) + +// testDB returns a migrated connection to the test database, truncating all +// Tidepool tables so each test starts clean. +func testDB(t *testing.T) *sql.DB { + t.Helper() + + databaseURL := os.Getenv("TIDEPOOL_TEST_DATABASE_URL") + if databaseURL == "" { + // In CI a missing database must fail loudly: skipping every + // postgres-backed test would let the suite go green while testing + // nothing. + if os.Getenv("CI") != "" { + t.Fatal("CI is set but TIDEPOOL_TEST_DATABASE_URL is not; " + + "the postgres-backed store tests must run in CI") + } + t.Skip("TIDEPOOL_TEST_DATABASE_URL not set; skipping postgres-backed store tests " + + "(run `make test` to start the postgres-test container and set it)") + } + + testDatabaseOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + testDatabase, testDatabaseErr = db.Open(ctx, databaseURL) + if testDatabaseErr != nil { + return + } + testDatabaseErr = db.MigrateUp(ctx, testDatabase) + }) + require.NoError(t, testDatabaseErr, "connect and migrate test database") + + _, err := testDatabase.ExecContext(context.Background(), + `TRUNCATE ap_objects, bridged_actors, communities, inbox_events RESTART IDENTITY`) + require.NoError(t, err, "truncate test tables") + + return testDatabase +} + +// Shared fixtures for readable tests. +const ( + testDID = "did:plc:ewvi7nxzyoun6zhxrhs64oiz" + testSecondDID = "did:plc:44ybard66vv44zksje25o7dz" + testCollection = "social.coves.community.post" + testRKey = "3jzfcijpj2z2a" + testCID = "bafyreib2rxk3rybk3aobmv5cjuql3bm2twh4jo5uxgf5kpqrsqxi3jgxte" + testUpdatedCID = "bafyreievgu2ty7qbiaaom5zhmkznsnajuzideek3lo7e65dwqlrvrxnmo4" + testAPObjectID = "https://lemmy.world/post/12345" + testAPActorID = "https://lemmy.world/u/alice" + testAPGroupID = "https://lemmy.world/c/technology" + testInstance = "lemmy.world" + testExpectedATURI = "at://" + testDID + "/" + testCollection + "/" + testRKey +) + +func testMapping() APObjectMapping { + publishedAt := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) + return APObjectMapping{ + APID: testAPObjectID, + APType: "Page", + OriginInstance: testInstance, + DID: testDID, + Collection: testCollection, + RKey: testRKey, + CID: testCID, + PublishedAt: &publishedAt, + } +} + +func testActor() BridgedActor { + return BridgedActor{ + APActorID: testAPActorID, + ActorType: ActorTypePerson, + DID: testDID, + Handle: "alice.lemmy-world.tidepool.example", + SigningKeyEncrypted: []byte("test-signing-key-bytes"), + // Consent is always stated explicitly: the zero value is rejected + // by validation, never coerced to "consented". + ConsentState: ConsentStateOK, + } +} + +func testCommunity() Community { + return Community{ + APGroupID: testAPGroupID, + DID: testDID, + PreferredUsername: "technology", + Instance: testInstance, + } +} -- 2.51.2