diff --git a/.github/workflows/golang.yml b/.github/workflows/golang.yml index 29f1967..1591479 100644 --- a/.github/workflows/golang.yml +++ b/.github/workflows/golang.yml @@ -15,11 +15,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Git Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: Set up Go tooling - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: - go-version: "1.24" + go-version-file: go.mod - name: Build run: make build - name: Test @@ -28,10 +28,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Git Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: Set up Go tooling - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: - go-version: "1.24" + go-version-file: go.mod - name: Lint run: make lint diff --git a/LICENSE b/LICENSE index dd96d0b..1c8a79a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ Dual MIT/Apache-2.0 License -Copyright (c) 2025 Bluesky Social PBC +Copyright (c) 2025-2026 Bluesky Social PBC Except as otherwise noted in individual files, this software is licensed under the MIT license (), or the Apache License, Version 2.0 (), at your option. diff --git a/Makefile b/Makefile index dca6d02..231757d 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,14 @@ help: ## Print info about all commands .PHONY: build build: ## Build all executables go build ./cmd/plcli + go build -o plc-replica ./cmd/replica .PHONY: all all: build .PHONY: test test: ## Run tests - go test -short ./... + go test -v -short ./didplc/... ./replica/... .PHONY: coverage-html coverage-html: ## Generate test coverage report and open in browser diff --git a/cmd/plcli/main.go b/cmd/plcli/main.go index c14680a..76b7961 100644 --- a/cmd/plcli/main.go +++ b/cmd/plcli/main.go @@ -10,7 +10,7 @@ import ( "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/did-method-plc/go-didplc" + "github.com/did-method-plc/go-didplc/didplc" "github.com/urfave/cli/v3" ) @@ -101,20 +101,21 @@ func main() { } h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) slog.SetDefault(slog.New(h)) - app.Run(context.Background(), os.Args) + if err := app.Run(context.Background(), os.Args); err != nil { + fmt.Println("Error:", err) + os.Exit(-1) + } } func runResolve(ctx context.Context, cmd *cli.Command) error { s := cmd.Args().First() if s == "" { - fmt.Println("need to provide DID as an argument") - os.Exit(-1) + return fmt.Errorf("need to provide DID as an argument") } did, err := syntax.ParseDID(s) if err != nil { - fmt.Println(err) - os.Exit(-1) + return err } c := didplc.Client{ @@ -154,8 +155,7 @@ func runSubmit(ctx context.Context, cmd *cli.Command) error { var did_string string if s == "" { if !op.IsGenesis() { - fmt.Println("a DID must be provided as argument for non-genesis ops") - os.Exit(-1) + return fmt.Errorf("a DID must be provided as argument for non-genesis ops") } // else, did string will be computed after signing } else { diff --git a/cmd/replica/README.md b/cmd/replica/README.md new file mode 100644 index 0000000..19009d5 --- /dev/null +++ b/cmd/replica/README.md @@ -0,0 +1,59 @@ +# PLC Replica Service + +The `replica` command implements a `did:plc` read-replica service that syncs operations from an upstream PLC directory service, and exposes the standard HTTP APIs for resolving and auditing DID documents. + +It performs full cryptographic validation of all inbound PLC operations, including enforcing constraints around operation nullification. + +``` +NAME: + replica - PLC directory replica server + +USAGE: + replica [global options] + +GLOBAL OPTIONS: + --postgres-url string PostgreSQL connection string (if set, uses Postgres instead of SQLite) [$POSTGRES_URL] + --sqlite-path string SQLite database file path (used when --postgres-url is not set) (default: "replica.db") [$SQLITE_PATH] + --http-addr string HTTP server listen address (default: ":8080") [$HTTP_ADDR] + --metrics-addr string Metrics HTTP server listen address (default: ":9464") [$METRICS_ADDR] + --no-ingest Disable ingestion from upstream directory (default: false) [$NO_INGEST] + --upstream-directory-url string Upstream PLC directory base URL (default: "https://plc.directory") [$UPSTREAM_DIRECTORY_URL] + --cursor-override int Starting cursor (sequence number) for ingestion (default: -1) [$CURSOR_OVERRIDE] + --num-workers int Number of validation worker threads (0 = auto) (default: 0) [$NUM_WORKERS] + --log-level string Log level (debug, info, warn, error) (default: "info") [$LOG_LEVEL] + --log-json Output logs in JSON format (default: false) [$LOG_JSON] + --help, -h show help +``` + +## HTTP API + +It exposes the following endpoints, as described in the `did:plc` [spec](https://web.plc.directory/spec/v0.1/did-plc) + +- `GET /{did}` (see Format Differences below) +- `GET /{did}/data` +- `GET /{did}/log` +- `GET /{did}/log/audit` +- `GET /{did}/log/last` + +Actually, some of these aren't mentioned in the spec, but they are in the [API docs](https://web.plc.directory/api/redoc) and implemented by the [reference implementation](https://github.com/did-method-plc/did-method-plc/tree/main/packages/server). + +It does not support POSTing DID updates to `/{did}` - it only discovers new operations by importing from the upstream instance. + +It does not currently implement the `/export` and `/export/stream` endpoints, although it may in the future. + +### DID Document Format Differences + +The reference implementation returns DID documents in `application/did+ld+json` format, whereas this replica returns them in `application/did+json` format. Both are described in the [DID specification](https://www.w3.org/TR/did-1.0/), but in practical terms the difference is that the `@context` field is missing. + +Secondarily, service identifiers include the DID ([relevant issue](https://github.com/did-method-plc/did-method-plc/issues/90)) + +Although these differences are spec-compliant, some PLC client libraries may have trouble with these differences. + + +## Databases + +The service supports either PostgreSQL or SQLite. Postgres has more horizontal scaling headroom on the read path, but SQLite performs better when backfilling. + +## Backfilling + +When the service is started for the first time, it has to "backfill" the entire PLC operation history from the upstream instance. Until it "catches up", it will not provide up-to-date responses to queries. Depending on your hardware, it should take less than 24h to complete a backfill (at time of writing). Backfilling tends to be bottlenecked by database throughput. diff --git a/cmd/replica/main.go b/cmd/replica/main.go new file mode 100644 index 0000000..5f34105 --- /dev/null +++ b/cmd/replica/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "runtime" + + "github.com/did-method-plc/go-didplc/replica" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/urfave/cli/v3" + "golang.org/x/sync/errgroup" +) + +func main() { + cmd := &cli.Command{ + Name: "plc-replica", + Usage: "PLC directory replica server", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "postgres-url", + Usage: "PostgreSQL connection string (if set, uses Postgres instead of SQLite)", + Sources: cli.EnvVars("POSTGRES_URL"), + }, + &cli.StringFlag{ + Name: "sqlite-path", + Usage: "SQLite database file path (used when --postgres-url is not set)", + Value: "replica.db", + Sources: cli.EnvVars("SQLITE_PATH"), + }, + &cli.StringFlag{ + Name: "bind", + Usage: "HTTP server listen address", + Value: ":8080", + Sources: cli.EnvVars("REPLICA_BIND"), + }, + &cli.StringFlag{ + Name: "metrics-addr", + Usage: "Metrics HTTP server listen address", + Value: ":9464", + Sources: cli.EnvVars("METRICS_ADDR"), + }, + &cli.BoolFlag{ + Name: "no-ingest", + Usage: "Disable ingestion from upstream directory", + Sources: cli.EnvVars("NO_INGEST"), + }, + &cli.StringFlag{ + Name: "upstream-directory-url", + Usage: "Upstream PLC directory base URL", + Value: "https://plc.directory", + Sources: cli.EnvVars("UPSTREAM_DIRECTORY_URL"), + }, + &cli.Int64Flag{ + Name: "cursor-override", + Usage: "Starting cursor (sequence number) for ingestion", + Value: -1, + Sources: cli.EnvVars("CURSOR_OVERRIDE"), + }, + &cli.IntFlag{ + Name: "num-workers", + Usage: "Number of validation worker threads (0 = auto)", + Value: 0, + Sources: cli.EnvVars("NUM_WORKERS"), + }, + &cli.StringFlag{ + Name: "log-level", + Usage: "Log level (debug, info, warn, error)", + Value: "info", + Sources: cli.EnvVars("LOG_LEVEL"), + }, + &cli.BoolFlag{ + Name: "log-json", + Usage: "Output logs in JSON format", + Sources: cli.EnvVars("LOG_JSON"), + }, + }, + Action: run, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + slog.Error("fatal error", "error", err) + os.Exit(1) + } +} + +func run(ctx context.Context, cmd *cli.Command) error { + // Parse configuration + postgresURL := cmd.String("postgres-url") + sqlitePath := cmd.String("sqlite-path") + httpAddr := cmd.String("bind") + metricsAddr := cmd.String("metrics-addr") + noIngest := cmd.Bool("no-ingest") + directoryURL := cmd.String("upstream-directory-url") + cursorOverride := cmd.Int64("cursor-override") + numWorkers := cmd.Int("num-workers") + logLevel := cmd.String("log-level") + logJSON := cmd.Bool("log-json") + + // Initialize logger + var level slog.Level + switch logLevel { + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + var handler slog.Handler + opts := &slog.HandlerOptions{Level: level} + if logJSON { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + logger := slog.New(handler) + slog.SetDefault(logger) + + if numWorkers <= 0 { + numWorkers = runtime.NumCPU() + } + + otelShutdown, err := setupOTel(ctx) + if err != nil { + return fmt.Errorf("otel setup: %w", err) + } + defer otelShutdown(context.Background()) + + var store *replica.GormOpStore + + if postgresURL != "" { + slog.Info("using database", "type", "postgres", "url", postgresURL) + store, err = replica.NewGormOpStoreWithPostgres(postgresURL, logger) + if err != nil { + return fmt.Errorf("failed to create postgres store: %w", err) + } + } else { + slog.Info("using database", "type", "sqlite", "path", sqlitePath) + store, err = replica.NewGormOpStoreWithSqlite(sqlitePath, logger) + if err != nil { + return fmt.Errorf("failed to create sqlite store: %w", err) + } + } + + server := replica.NewServer(store, httpAddr, logger) + g, gctx := errgroup.WithContext(ctx) + + g.Go(server.Run) + + g.Go(func() error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + slog.Info("metrics server listening", "addr", metricsAddr) + return http.ListenAndServe(metricsAddr, mux) + }) + + if !noIngest { + ingestor, err := replica.NewIngestor(store, directoryURL, cursorOverride, numWorkers, logger) + if err != nil { + return err + } + g.Go(func() error { + return ingestor.Run(gctx) + }) + } + + return g.Wait() +} diff --git a/cmd/replica/otel.go b/cmd/replica/otel.go new file mode 100644 index 0000000..e2bf306 --- /dev/null +++ b/cmd/replica/otel.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "errors" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +func setupOTel(ctx context.Context) (shutdown func(context.Context) error, err error) { + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName("plc-replica"), + ), + ) + if err != nil { + return nil, err + } + + // Traces: OTLP HTTP exporter, configured via OTEL_EXPORTER_OTLP_* env vars. + traceExporter, err := otlptracehttp.New(ctx) + if err != nil { + return nil, err + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + // Metrics: Prometheus exporter, served via /metrics HTTP endpoint. + promExporter, err := prometheus.New() + if err != nil { + return nil, err + } + + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(promExporter), + sdkmetric.WithResource(res), + ) + otel.SetMeterProvider(mp) + + shutdown = func(ctx context.Context) error { + return errors.Join(tp.Shutdown(ctx), mp.Shutdown(ctx)) + } + return shutdown, nil +} diff --git a/client.go b/didplc/client.go similarity index 100% rename from client.go rename to didplc/client.go diff --git a/diddoc.go b/didplc/diddoc.go similarity index 100% rename from diddoc.go rename to didplc/diddoc.go diff --git a/didplc/log.go b/didplc/log.go new file mode 100644 index 0000000..8c8e337 --- /dev/null +++ b/didplc/log.go @@ -0,0 +1,108 @@ +package didplc + +import ( + "context" + "fmt" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +type LogEntry struct { + DID string `json:"did"` + Operation OpEnum `json:"operation"` + CID string `json:"cid"` + Nullified bool `json:"nullified"` + CreatedAt string `json:"createdAt"` +} + +// Checks self-consistency of this log entry in isolation. Does not access other context or log entries. +func (le *LogEntry) Validate() error { + op := le.Operation.AsOperation() + if op == nil { + return fmt.Errorf("invalid operation type") + } + if op.CID().String() != le.CID { + return fmt.Errorf("log entry CID didn't match computed operation CID") + } + if !op.IsSigned() { + return fmt.Errorf("log entry was not signed") + } + if op.IsGenesis() { + did, err := op.DID() + if err != nil { + return err + } + if le.DID != did { + return fmt.Errorf("log entry DID didn't match computed genesis operation DID") + } + if _, err := VerifySignatureAny(op, op.EquivalentRotationKeys()); err != nil { + return fmt.Errorf("failed to validate op genesis signature: %v", err) + } + } + return nil +} + +// Verifies an ordered list of log operations for a single DID. +// +// Can be a full audit log (with nullified entries), or a simple log (only "active" entries). +func VerifyOpLog(entries []LogEntry) error { + if len(entries) == 0 { + return fmt.Errorf("can't verify empty operation log") + } + + did := entries[0].DID + mos := NewMemOpStore() + ctx := context.Background() + + for _, oe := range entries { + if oe.DID != did { + return fmt.Errorf("inconsistent DID") + } + // NOTE: we do not call oe.Validate() here because we'd end up verifying + // genesis op signatures twice. + // All validation is performed inside VerifyOperation() + op := oe.Operation.AsOperation() + if op == nil { + return fmt.Errorf("invalid operation type") + } + + datetime, err := syntax.ParseDatetime(oe.CreatedAt) + if err != nil { + return err + } + timestamp := datetime.Time() + + po, _, err := VerifyOperation(ctx, mos, did, op, timestamp) + if err != nil { + return err + } + // extra CID check (since oe.CID is not checked inside VerifyOperation) + if po.OpCid != oe.CID { + return fmt.Errorf("inconsistent CID") + } + + err = mos.CommitOperations(ctx, []*PreparedOperation{po}) + if err != nil { + return err + } + } + + // check consistency of `nullified` fields + // Note: This has to be a separate loop because an op's eventual nullification status can't be known until after we've processed all operations + for idx, oe := range entries { + if idx == 0 { + if oe.Nullified { + return fmt.Errorf("genesis op cannot be nullified") + } + } + status, err := mos.GetEntry(ctx, did, oe.CID) + if err != nil { + return err + } + if status.Nullified != oe.Nullified { + return fmt.Errorf("inconsistent nullification status for %s %s", did, oe.CID) + } + } + + return nil +} diff --git a/manual_test.go b/didplc/manual_test.go similarity index 100% rename from manual_test.go rename to didplc/manual_test.go diff --git a/operation.go b/didplc/operation.go similarity index 92% rename from operation.go rename to didplc/operation.go index fcf785d..e53a1a7 100644 --- a/operation.go +++ b/didplc/operation.go @@ -2,6 +2,7 @@ package didplc import ( "crypto/sha256" + "database/sql/driver" "encoding/base32" "encoding/base64" "encoding/json" @@ -332,14 +333,14 @@ func (op *LegacyOp) VerifySignature(pub atcrypto.PublicKey) error { func (op *LegacyOp) Doc(did string) (Doc, error) { // NOTE: could re-implement this by calling op.RegularOp().Doc() svc := []DocService{ - DocService{ + { ID: did + "#atproto_pds", Type: "AtprotoPersonalDataServer", ServiceEndpoint: op.Service, }, } vm := []DocVerificationMethod{ - DocVerificationMethod{ + { ID: did + "#atproto", Type: "Multikey", Controller: did, @@ -484,6 +485,31 @@ func (o *OpEnum) UnmarshalJSON(b []byte) error { } } +// Value implements the driver.Valuer interface +func (o OpEnum) Value() (driver.Value, error) { + // TODO: consider using CBOR here? + return o.MarshalJSON() +} + +// Scan implements the sql.Scanner interface +func (o *OpEnum) Scan(value interface{}) error { + if value == nil { + return nil + } + + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("failed to scan OpEnum: expected []byte or string, got %T", value) + } + + return o.UnmarshalJSON(bytes) +} + // returns a new signed PLC operation using the provided atproto-specific metdata func NewAtproto(priv atcrypto.PrivateKey, handle string, pdsEndpoint string, rotationKeys []string) (RegularOp, error) { @@ -528,3 +554,25 @@ func (oe *OpEnum) AsOperation() Operation { return nil } } + +// WrapOperation converts an Operation interface to an OpEnum for marshaling +func WrapOperation(op Operation) (*OpEnum, error) { + if op == nil { + return nil, fmt.Errorf("cannot wrap nil operation") + } + + opEnum := &OpEnum{} + + switch v := op.(type) { + case *RegularOp: + opEnum.Regular = v + case *LegacyOp: + opEnum.Legacy = v + case *TombstoneOp: + opEnum.Tombstone = v + default: + return nil, fmt.Errorf("unknown operation type: %T", op) + } + + return opEnum, nil +} diff --git a/operation_export_test.go b/didplc/operation_export_test.go similarity index 96% rename from operation_export_test.go rename to didplc/operation_export_test.go index e0cf558..1cfc15c 100644 --- a/operation_export_test.go +++ b/didplc/operation_export_test.go @@ -47,7 +47,7 @@ func TestExportLogEntryValidate(t *testing.T) { assert := assert.New(t) - knownBadCIDsList, err := loadJSONStringArray("testdata/known_bad_cids.json") + knownBadCIDsList, err := loadJSONStringArray("../testdata/known_bad_cids.json") if err != nil { t.Fatal(err) } @@ -132,7 +132,7 @@ func TestExportAuditLogEntryValidate(t *testing.T) { assert := assert.New(t) - knownBadDIDsList, err := loadJSONStringArray("testdata/known_bad_dids.json") + knownBadDIDsList, err := loadJSONStringArray("../testdata/known_bad_dids.json") if err != nil { t.Fatal(err) } diff --git a/operation_test.go b/didplc/operation_test.go similarity index 68% rename from operation_test.go rename to didplc/operation_test.go index 37cef38..2a6491a 100644 --- a/operation_test.go +++ b/didplc/operation_test.go @@ -14,29 +14,29 @@ import ( ) var VALID_LOG_PATHS = [...]string{ - "testdata/log_bskyapp.json", - "testdata/log_legacy_dholms.json", - "testdata/log_bnewbold_robocracy.json", - "testdata/log_empty_rotation_keys.json", - "testdata/log_duplicate_rotation_keys.json", // XXX: invalid according to spec, valid according to TS reference impl - "testdata/log_nullification.json", - "testdata/log_nullification_nontrivial.json", - "testdata/log_nullification_at_exactly_72h.json", - "testdata/log_nullified_tombstone.json", - "testdata/log_tombstone.json", + "../testdata/log_bskyapp.json", + "../testdata/log_legacy_dholms.json", + "../testdata/log_bnewbold_robocracy.json", + "../testdata/log_empty_rotation_keys.json", + "../testdata/log_duplicate_rotation_keys.json", // XXX: invalid according to spec, valid according to TS reference impl + "../testdata/log_nullification.json", + "../testdata/log_nullification_nontrivial.json", + "../testdata/log_nullification_at_exactly_72h.json", + "../testdata/log_nullified_tombstone.json", + "../testdata/log_tombstone.json", } var INVALID_LOG_PATHS = [...]string{ - "testdata/log_invalid_sig_b64_padding_chars.json", - "testdata/log_invalid_sig_b64_padding_bits.json", - "testdata/log_invalid_sig_b64_newline.json", - "testdata/log_invalid_sig_der.json", - "testdata/log_invalid_sig_p256_high_s.json", - "testdata/log_invalid_sig_k256_high_s.json", - "testdata/log_invalid_nullification_reused_key.json", - "testdata/log_invalid_nullification_too_slow.json", - "testdata/log_invalid_update_nullified.json", - "testdata/log_invalid_update_tombstoned.json", + "../testdata/log_invalid_sig_b64_padding_chars.json", + "../testdata/log_invalid_sig_b64_padding_bits.json", + "../testdata/log_invalid_sig_b64_newline.json", + "../testdata/log_invalid_sig_der.json", + "../testdata/log_invalid_sig_p256_high_s.json", + "../testdata/log_invalid_sig_k256_high_s.json", + "../testdata/log_invalid_nullification_reused_key.json", + "../testdata/log_invalid_nullification_too_slow.json", + "../testdata/log_invalid_update_nullified.json", + "../testdata/log_invalid_update_tombstoned.json", } func loadTestLogEntries(t *testing.T, p string) []LogEntry { @@ -100,22 +100,22 @@ func TestLogEntryInvalid(t *testing.T) { func TestAuditLogInvalidSigEncoding(t *testing.T) { assert := assert.New(t) - entries := loadTestLogEntries(t, "testdata/log_invalid_sig_b64_padding_chars.json") + entries := loadTestLogEntries(t, "../testdata/log_invalid_sig_b64_padding_chars.json") assert.ErrorContains(VerifyOpLog(entries), "illegal base64") - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_b64_padding_bits.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_b64_padding_bits.json") assert.ErrorContains(VerifyOpLog(entries), "illegal base64") - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_b64_newline.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_b64_newline.json") assert.ErrorContains(VerifyOpLog(entries), "CRLF") - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_der.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_der.json") assert.EqualError(VerifyOpLog(entries), "crytographic signature invalid") // Note: there is no reliable way to detect DER-encoded signatures syntactically, so a generic invalid signature error is expected - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_p256_high_s.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_p256_high_s.json") assert.EqualError(VerifyOpLog(entries), "crytographic signature invalid") - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_k256_high_s.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_k256_high_s.json") assert.EqualError(VerifyOpLog(entries), "crytographic signature invalid") } @@ -123,20 +123,20 @@ func TestAuditLogInvalidSigEncoding(t *testing.T) { func TestAuditLogInvalidNullification(t *testing.T) { assert := assert.New(t) - entries := loadTestLogEntries(t, "testdata/log_invalid_nullification_reused_key.json") + entries := loadTestLogEntries(t, "../testdata/log_invalid_nullification_reused_key.json") assert.EqualError(VerifyOpLog(entries), "crytographic signature invalid") // TODO: This is the expected error message for the current impl logic. This could be improved. - entries = loadTestLogEntries(t, "testdata/log_invalid_nullification_too_slow.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_nullification_too_slow.json") assert.ErrorContains(VerifyOpLog(entries), "cannot nullify op after 72h") - entries = loadTestLogEntries(t, "testdata/log_invalid_update_nullified.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_update_nullified.json") assert.EqualError(VerifyOpLog(entries), "prev CID is nullified") } func TestAuditLogInvalidTombstoneUpdate(t *testing.T) { assert := assert.New(t) - entries := loadTestLogEntries(t, "testdata/log_invalid_update_tombstoned.json") + entries := loadTestLogEntries(t, "../testdata/log_invalid_update_tombstoned.json") assert.EqualError(VerifyOpLog(entries), "no keys to verify against") // TODO: This is the expected error message for the current impl logic. This could be improved. } diff --git a/didplc/opstore.go b/didplc/opstore.go new file mode 100644 index 0000000..3bd5933 --- /dev/null +++ b/didplc/opstore.go @@ -0,0 +1,301 @@ +package didplc + +import ( + "context" + "fmt" + "sync" + "time" +) + +type OpEntry struct { + DID string + CreatedAt time.Time + Nullified bool + LastChild string // CID of most recent operation with `prev` referencing this op + AllowedKeys []string // the set of public did:keys currently allowed to update from this op + Op Operation + OpCid string +} + +// PreparedOperation contains all the information needed to commit a validated operation. +type PreparedOperation struct { + DID string + PrevHead string + NullifiedOps []string // CIDs of any operations being nullified + KeyIndex int + CreatedAt time.Time + Op Operation + OpCid string +} + +type OpStore interface { + // GetEntry returns metadata about a specific operation, plus the operation itself. + // Returns nil if the DID does not exist (NOT an error). + GetEntry(ctx context.Context, did string, cid string) (*OpEntry, error) + + // Like GetEntry, but returns the data for the most recent valid operation for a DID. + // Returns nil if the DID does not exist (NOT an error). + GetLatest(ctx context.Context, did string) (*OpEntry, error) + + // CommitOperations atomically commits a batch of prepared operations to the store. + // All operations in the batch are committed, or none are (all-or-nothing). + // It is invalid to have multiple operations for the same DID in the same batch. + // + // For each PreparedOperation, `prevHead` MUST match the OpCid value returned by an earlier call to GetLatest. Or if GetLatest returned nil, `prevHead` must be "". + // PreparedOperations created via VerifyOperation() will always have `prevHead` set appropriately. + // If multiple updates to the same DID are attempted concurrently, one will return an error due to head mismatch. + CommitOperations(ctx context.Context, ops []*PreparedOperation) error +} + +type MemOpStore struct { + head map[string]string // DID -> CID (head) + entries map[string]*OpEntry // CID -> OpEntry + lock sync.RWMutex +} + +func NewMemOpStore() *MemOpStore { + return &MemOpStore{ + head: make(map[string]string), + entries: make(map[string]*OpEntry), + } +} + +// GetLatest returns the entry for the most recent valid operation for a DID. +// Returns nil if the DID does not exist. +func (store *MemOpStore) GetLatest(ctx context.Context, did string) (*OpEntry, error) { + store.lock.RLock() + defer store.lock.RUnlock() + + head, exists := store.head[did] + if !exists { + return nil, nil + } + return store.GetEntry(ctx, did, head) +} + +// GetEntry returns the entry for a specific operation. +// Returns nil if the operation does not exist. +func (store *MemOpStore) GetEntry(ctx context.Context, did string, cid string) (*OpEntry, error) { + store.lock.RLock() + defer store.lock.RUnlock() + + status, exists := store.entries[cid] + if !exists { + return nil, nil + } + + if status.DID != did { + // This implies an implementation bug, should be unreachable + return nil, fmt.Errorf("operation belongs to a different DID") + } + + return status, nil +} + +// CommitOperations atomically commits a batch of prepared operations to the store. +// All operations in the batch are committed or none are (all-or-nothing). +func (store *MemOpStore) CommitOperations(ctx context.Context, ops []*PreparedOperation) error { + store.lock.Lock() + defer store.lock.Unlock() + + // Verify all heads upfront before making any modifications + // (a db implementation can do this in the main loop and roll back the tx on mismatch) + for _, prepOp := range ops { + currentHead := store.head[prepOp.DID] + if currentHead != prepOp.PrevHead { + return fmt.Errorf("head CID mismatch for DID %s", prepOp.DID) + } + } + + // Now apply all modifications + for _, prepOp := range ops { + // Handle nullifications + for _, nullifiedCid := range prepOp.NullifiedOps { + status := store.entries[nullifiedCid] + if status == nil { + // This implies an implementation bug, should be unreachable + return fmt.Errorf("operation not found during nullification: %s", nullifiedCid) + } + if status.DID != prepOp.DID { + // This implies an implementation bug, should be unreachable + return fmt.Errorf("operation belongs to different DID during nullification") + } + status.Nullified = true + } + + // Update previous operation's metadata if not a genesis op + if prepOp.PrevHead != "" { + prevCidStr := prepOp.Op.PrevCIDStr() + prevStatus := store.entries[prevCidStr] + if prevStatus == nil { + // This implies an implementation bug, should be unreachable + return fmt.Errorf("previous operation not found: %s", prevCidStr) + } + + // Trim allowed keys and set last child + prevStatus.AllowedKeys = prevStatus.AllowedKeys[:prepOp.KeyIndex] + prevStatus.LastChild = prepOp.OpCid + } + + store.entries[prepOp.OpCid] = &OpEntry{ + DID: prepOp.DID, + CreatedAt: prepOp.CreatedAt, + Nullified: false, + LastChild: "", + AllowedKeys: prepOp.Op.EquivalentRotationKeys(), + Op: prepOp.Op, + OpCid: prepOp.OpCid, + } + + // Update head + store.head[prepOp.DID] = prepOp.OpCid + } + + return nil +} + +// getValidationContext retrieves the initial information required to validate a signature for a particular operation. +// `cidStr` corresponds to the `prev` field of the operation you're trying to validate. +// For genesis ops (i.e. prev==nil), pass cidStr=="". +// +// Returns the current "head" CID of the passed DID and the OpStatus for the previous operation. +// Any subsequent calls to CommitValidatedOperations must pass the corresponding head, OpStatus values. +func getValidationContext(ctx context.Context, store OpStore, did string, cidStr string) (string, *OpEntry, bool, error) { + head, err := store.GetLatest(ctx, did) + if err != nil { + return "", nil, false, err + } + + if head == nil { + if cidStr != "" { + return "", nil, true, fmt.Errorf("DID not found") + } + return "", nil, false, nil // Not an error condition! just means DID is not created yet + } + + if cidStr == "" { + return "", nil, true, fmt.Errorf("expected genesis op but DID already exists") + } + + if head.OpCid == cidStr { + // shortcut: prev == head + return head.OpCid, head, false, nil + } + + status, err := store.GetEntry(ctx, did, cidStr) + if err != nil { + return "", nil, false, err + } + if status == nil { + return "", nil, true, fmt.Errorf("prev cid does not exist %s", cidStr) + } + + return head.OpCid, status, false, nil +} + +// VerifyOperation validates and prepares a single operation for commit. +// It verifies the signature, validates timestamp consistency, and computes the nullification list. +// On success, returns a PreparedOperation ready to be committed to the store. +// On error, the returned boolean is true if the operation was *definitely* invalid, or false if the error was OpStore-related (e.g. transient database connection issue) and *may* be resolved by retrying. +func VerifyOperation(ctx context.Context, store OpStore, did string, op Operation, createdAt time.Time) (*PreparedOperation, bool, error) { + head, prevStatus, opIsInvalid, err := getValidationContext(ctx, store, did, op.PrevCIDStr()) + if err != nil { + return nil, opIsInvalid, err + } + + // Determine allowed keys for signature verification + var allowedKeys []string + if op.IsGenesis() { + calcDid, err := op.DID() + if err != nil { + return nil, true, err + } + if calcDid != did { + return nil, true, fmt.Errorf("genesis DID does not match") + } + allowedKeys = op.EquivalentRotationKeys() + } else { + if prevStatus == nil { + return nil, true, fmt.Errorf("prevStatus required for non-genesis operation") + } + allowedKeys = prevStatus.AllowedKeys + } + + // Verify signature + keyIdx, err := VerifySignatureAny(op, allowedKeys) + if err != nil { + return nil, true, err + } + + // Create the prepared operation + prepOp := PreparedOperation{ + DID: did, + PrevHead: head, + KeyIndex: keyIdx, + CreatedAt: createdAt, + Op: op, + OpCid: op.CID().String(), + } + + // Genesis operations don't have nullifications or timestamp constraints + if head == "" { + prepOp.NullifiedOps = nil + return &prepOp, false, nil // success + } + + if prevStatus.Nullified { + return nil, true, fmt.Errorf("prev CID is nullified") + } + + if prevStatus.LastChild == "" { + // Regular update (not a nullification) + // Validate timestamp order + if createdAt.Sub(prevStatus.CreatedAt) <= 0 { + return nil, true, fmt.Errorf("invalid operation timestamp order") + } + prepOp.NullifiedOps = nil + } else { + // This is a nullification - validate timestamp against head + headStatus, err := store.GetEntry(ctx, did, head) + if err != nil { + return nil, false, err + } + if headStatus == nil { // should be unreachable, implies invalid db state + return nil, false, fmt.Errorf("failed to retrieve head") + } + if createdAt.Sub(headStatus.CreatedAt) <= 0 { + return nil, true, fmt.Errorf("invalid operation timestamp order") + } + + // Validate 72h constraint and build nullification list + nullifiedOps := []string{} + currentCid := prevStatus.LastChild + isFirstIteration := true + + for currentCid != "" { + nullifiedOps = append(nullifiedOps, currentCid) + status, err := store.GetEntry(ctx, did, currentCid) + if err != nil { + return nil, false, err + } + if status == nil { // should be unreachable, implies invalid db state + return nil, false, fmt.Errorf("failed to walk nullification chain") + } + + // Check 72h constraint for the first (oldest) nullified operation + if isFirstIteration { + if createdAt.Sub(status.CreatedAt) > 72*time.Hour { + return nil, true, fmt.Errorf("cannot nullify op after 72h (%s - %s = %s)", + createdAt, status.CreatedAt, createdAt.Sub(status.CreatedAt)) + } + isFirstIteration = false + } + + currentCid = status.LastChild + } + + prepOp.NullifiedOps = nullifiedOps + } + + return &prepOp, false, nil // success +} diff --git a/doc.go b/doc.go deleted file mode 100644 index 9968185..0000000 --- a/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -/* -Implementation of DID PLC method: creation and validation of operations. -*/ -package didplc diff --git a/extra/pg/README.md b/extra/pg/README.md new file mode 100644 index 0000000..2c0ce3c --- /dev/null +++ b/extra/pg/README.md @@ -0,0 +1,65 @@ +# pg + +Helpers for working with postgres (borrowed from https://github.com/did-method-plc/did-method-plc/tree/main/packages/server/pg ) + +## Usage + +### `with-test-db.sh` + +This script allows you to run any command with a fresh, ephemeral/single-use postgres database available. When the script starts a Dockerized postgres container starts-up, and when the script completes that container is removed. + +The environment variable `DATABASE_URL` will be set with a connection string that can be used to connect to the database. The [`PG*` environment variables](https://www.postgresql.org/docs/current/libpq-envars.html) that are recognized by libpq (i.e. used by the `psql` client) are also set. + +**Example** + +``` +$ ./with-test-db.sh psql -c 'select 1;' +[+] Running 1/1 + ⠿ Container pg-db_test-1 Healthy 1.8s + + ?column? +---------- + 1 +(1 row) + + +[+] Running 1/1 + ⠿ Container pg-db_test-1 Stopped 0.1s +Going to remove pg-db_test-1 +[+] Running 1/0 + ⠿ Container pg-db_test-1 Removed +``` + +### `docker-compose.yaml` + +The Docker compose file can be used to run containerized versions of postgres either for single use (as is used by `with-test-db.sh`), or for longer-term use. These are setup as separate services named `test_db` and `db` respectively. In both cases the database is available on the host machine's `localhost` and credentials are: + +- Username: pg +- Password: password + +However, each service uses a different port, documented below, to avoid conflicts. + +#### `test_db` service for single use + +The single-use `test_db` service does not have any persistent storage. When the container is removed, data in the database disappears with it. + +This service runs on port `5433`. + +``` +$ docker compose up test_db # start container +$ docker compose stop test_db # stop container +$ docker compose rm test_db # remove container +``` + +#### `db` service for persistent use + +The `db` service has persistent storage on the host machine managed by Docker under a volume named `pg_plc_db`. When the container is removed, data in the database will remain on the host machine. In order to start fresh, you would need to remove the volume. + +This service runs on port `5432`. + +``` +$ docker compose up db -d # start container +$ docker compose stop db # stop container +$ docker compose rm db # remove container +$ docker volume rm pg_plc_db # remove volume +``` diff --git a/extra/pg/docker-compose.yaml b/extra/pg/docker-compose.yaml new file mode 100644 index 0000000..5c32837 --- /dev/null +++ b/extra/pg/docker-compose.yaml @@ -0,0 +1,27 @@ +version: '3.8' +services: + # An ephermerally-stored postgres database for single-use test runs + db_test: &db_test + image: postgres:14.4-alpine + environment: + - POSTGRES_USER=pg + - POSTGRES_PASSWORD=password + ports: + - '5433:5432' + # Healthcheck ensures db is queryable when `docker-compose up --wait` completes + healthcheck: + test: 'pg_isready -U pg' + interval: 500ms + timeout: 10s + retries: 20 + # A persistently-stored postgres database + db: + <<: *db_test + ports: + - '5432:5432' + healthcheck: + disable: true + volumes: + - plc_db:/var/lib/postgresql/data +volumes: + plc_db: diff --git a/extra/pg/with-test-db.sh b/extra/pg/with-test-db.sh new file mode 100755 index 0000000..40db356 --- /dev/null +++ b/extra/pg/with-test-db.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env sh + +# Example usage: +# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;' + +dir=$(dirname $0) +compose_file="$dir/docker-compose.yaml" + +docker compose -f $compose_file up --wait --force-recreate db_test +echo # newline + +trap on_sigint INT +on_sigint() { + echo # newline + docker compose -f $compose_file rm -f --stop --volumes db_test + exit $? +} + +# Based on creds in compose.yaml +export PGPORT=5433 +export PGHOST=localhost +export PGUSER=pg +export PGPASSWORD=password +export PGDATABASE=postgres +export POSTGRES_URL="postgresql://pg:password@localhost:5433/postgres" +sleep 1 +"$@" +code=$? + +echo # newline +docker compose -f $compose_file rm -f --stop --volumes db_test + +exit $code diff --git a/go.mod b/go.mod index 1aba33a..6ee8378 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,55 @@ module github.com/did-method-plc/go-didplc -go 1.24 +go 1.25 -toolchain go1.24.1 +toolchain go1.25.1 require ( github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe + github.com/emirpasic/gods v1.18.1 + github.com/gorilla/websocket v1.5.3 github.com/ipfs/go-cid v0.4.1 github.com/ipfs/go-ipld-cbor v0.1.0 - github.com/stretchr/testify v1.10.0 + github.com/orandin/slog-gorm v1.4.0 + github.com/prometheus/client_golang v1.23.2 + github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v3 v3.4.1 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 + go.opentelemetry.io/otel/exporters/prometheus v0.61.0 + go.opentelemetry.io/otel/metric v1.39.0 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/sdk/metric v1.39.0 + golang.org/x/sync v0.19.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/carlmjohnson/versioninfo v0.22.5 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect github.com/ipfs/go-block-format v0.2.0 // indirect github.com/ipfs/go-ipfs-util v0.0.3 // indirect github.com/ipfs/go-ipld-format v0.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect @@ -25,15 +57,31 @@ require ( github.com/multiformats/go-multibase v0.2.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.0.7 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/sys v0.22.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.0.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.2.1 // indirect ) diff --git a/go.sum b/go.sum index 4d21060..d4f787e 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,42 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo= github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck= +github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= +github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= @@ -17,10 +47,32 @@ github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopo github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= @@ -35,10 +87,26 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU= +github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA= 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/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= @@ -47,8 +115,11 @@ github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hg github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= @@ -60,22 +131,75 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= +go.opentelemetry.io/otel/exporters/prometheus v0.61.0 h1:cCyZS4dr67d30uDyh8etKM2QyDsQ4zC9ds3bdbrVoD0= +go.opentelemetry.io/otel/exporters/prometheus v0.61.0/go.mod h1:iivMuj3xpR2DkUrUya3TPS/Z9h3dz7h01GxU+fQBRNg= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY= +google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY= +google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/log.go b/log.go deleted file mode 100644 index f0531f0..0000000 --- a/log.go +++ /dev/null @@ -1,286 +0,0 @@ -package didplc - -import ( - "errors" - "fmt" - "sync" - "time" - - "github.com/bluesky-social/indigo/atproto/syntax" -) - -type opStatus struct { - DID string - CreatedAt time.Time // fields below this line may be mutated - Nullified bool - LastChild string // CID - AllowedKeys []string // the set of public did:keys currently allowed to update from this op -} - -// Note: logValidationContext is designed such that it could later be turned into an interface, -// optionally backed by a db rather than in-memory -// Note: ops are globally unique by CID, so opStatus map can be shared across all DIDs -type logValidationContext struct { - head map[string]string // DID -> CID, tracks most recent valid op for a particular DID - opStatus map[string]*opStatus // CID -> OpStatus - lock sync.RWMutex -} - -var errLogValidationUnrecoverableInternalError = errors.New("logValidationContext internal state has become inconsistent. This is very bad and should be impossible") - -func newLogValidationContext() *logValidationContext { - return &logValidationContext{ - head: make(map[string]string), - opStatus: make(map[string]*opStatus), - } -} - -// Retrieve the information required to validate a signature for a particular operation, where `cidStr` -// corresponds to the `prev` field of the operation you're trying to validate. -// If you're validating a genesis op (i.e. prev==nil), pass cidStr=="" -// -// The returned string is the current "head" CID of the passed DID. -// Any subsequent calls to CommitValidOperation must pass the corresponding head, opStatus values. -// -// This method may also be used to inspect the nullification status and/or createdAt timestamp for a particular op (by did+cid) -func (lvc *logValidationContext) GetValidationContext(did string, cidStr string) (string, *opStatus, error) { - lvc.lock.RLock() - defer lvc.lock.RUnlock() - - head, exists := lvc.head[did] - if !exists { - if cidStr != "" { - return "", nil, fmt.Errorf("DID not found") - } - return "", nil, nil // Not an error condition! just means DID is not created yet - } - status := lvc.opStatus[cidStr] - if status == nil { - return "", nil, fmt.Errorf("CID not found") - } - if status.DID != did { - return "", nil, fmt.Errorf("op belongs to a different DID") - } - - // make a deep copy of the status struct so that concurrent mutations are safe - statusCopy := *status - statusCopy.AllowedKeys = make([]string, len(status.AllowedKeys)) - copy(statusCopy.AllowedKeys, status.AllowedKeys) - - return head, &statusCopy, nil -} - -// `head` and `prevStatus` MUST be values that were returned from a previous call to GetValidationContext, with the same `did`. -// The caller is responsible for syntax validation and signature verification of the Operation. -// CommitValidOperation will ensure that: -// 1. If this is the first operation for a particular DID, it must be a genesis operation -// 2. Else, it must not be a genesis operation. -// 3. The passed `createdAt` timestamp is greater than that of the current `head` op -// 4. If the operation nullifies a previous operation, the nullified op is less than (or exactly equal to) 72h old -// 5. This DID has not been updated since the corresponding GetValidationContext call -// -// Additionally, the lvc head+opStatus maps are updated to reflect the changes (including updating nullification status if applicable). -// -// Although it should be unreachable, errLogValidationUnrecoverableInternalError -// may be returned if the logValidationContext internal state has become inconsistent. -// This could happen due to an implementation bug, or if an invalid prevStatus is passed -// (one not produced by an earlier call to GetValidationContext). -func (lvc *logValidationContext) CommitValidOperation(did string, head string, prevStatus *opStatus, op Operation, createdAt time.Time, keyIndex int) error { - thisCid := op.CID().String() // CID() involves expensive-ish serialisation/hashing, best to keep out of the critical section - - lvc.lock.Lock() - defer lvc.lock.Unlock() - - if head != lvc.head[did] { - return fmt.Errorf("head CID mismatch") - } - if head == "" { - if !op.IsGenesis() { - return fmt.Errorf("expected genesis op") - } - } else { - if op.IsGenesis() { - return fmt.Errorf("unexpected genesis op") - } - if prevStatus == nil { - return fmt.Errorf("invalid prevStatus") - } - if prevStatus.Nullified { - return fmt.Errorf("prev CID is nullified") - } - if prevStatus.LastChild == "" { // regular update (not a nullification) - // note: prevStatus == c.opStatus[head] - if createdAt.Sub(prevStatus.CreatedAt) <= 0 { - return fmt.Errorf("invalid operation timestamp order") - } - } else { // this is a nullification. prevStatus.LastChild is the CID of the op being nullified - // note: prevStatus != c.opStatus[head] - headStatus := lvc.opStatus[head] - if headStatus == nil { - return errLogValidationUnrecoverableInternalError - } - if createdAt.Sub(headStatus.CreatedAt) <= 0 { - return fmt.Errorf("invalid operation timestamp order") - } - lastChildStatus := lvc.opStatus[prevStatus.LastChild] - if lastChildStatus == nil { - return errLogValidationUnrecoverableInternalError - } - if createdAt.Sub(lastChildStatus.CreatedAt) > 72*time.Hour { - return fmt.Errorf("cannot nullify op after 72h (%s - %s = %s)", createdAt, prevStatus.CreatedAt, createdAt.Sub(prevStatus.CreatedAt)) - } - err := lvc.markNullifiedOp(did, prevStatus.LastChild) // recursive - if err != nil { - return err // should never happen, if it does we're in a broken state - } - } - prevStatus.AllowedKeys = prevStatus.AllowedKeys[:keyIndex] - prevStatus.LastChild = thisCid - lvc.opStatus[op.PrevCIDStr()] = prevStatus // prevStatus was a copy so we need to write it back - } - lvc.head[did] = thisCid - lvc.opStatus[thisCid] = &opStatus{ - DID: did, - CreatedAt: createdAt, - Nullified: false, - LastChild: "", - AllowedKeys: op.EquivalentRotationKeys(), - } - return nil -} - -// Recurses if more than one op needs to be nullified (if the nullified op has descendents) -// Note: lvc.lock is expected to be held by caller -func (lvc *logValidationContext) markNullifiedOp(did string, cidStr string) error { - if cidStr == "" { - return nil - } - op := lvc.opStatus[cidStr] - if op == nil { // this *should* be unreachable - return errLogValidationUnrecoverableInternalError - } - if op.DID != did { // likewise - return errLogValidationUnrecoverableInternalError - } - if op.Nullified { - return nil - } - op.Nullified = true - return lvc.markNullifiedOp(did, op.LastChild) -} - -type LogEntry struct { - DID string `json:"did"` - Operation OpEnum `json:"operation"` - CID string `json:"cid"` - Nullified bool `json:"nullified"` - CreatedAt string `json:"createdAt"` -} - -// Checks self-consistency of this log entry in isolation. Does not access other context or log entries. -func (le *LogEntry) Validate() error { - op := le.Operation.AsOperation() - if op == nil { - return fmt.Errorf("invalid operation type") - } - if op.CID().String() != le.CID { - return fmt.Errorf("log entry CID didn't match computed operation CID") - } - if !op.IsSigned() { - return fmt.Errorf("log entry was not signed") - } - if op.IsGenesis() { - did, err := op.DID() - if err != nil { - return err - } - if le.DID != did { - return fmt.Errorf("log entry DID didn't match computed genesis operation DID") - } - if _, err := VerifySignatureAny(op, op.EquivalentRotationKeys()); err != nil { - return fmt.Errorf("failed to validate op genesis signature: %v", err) - } - } - return nil -} - -// Verifies an ordered list of log operations for a single DID. -// -// Can be a full audit log (with nullified entries), or a simple log (only "active" entries). -func VerifyOpLog(entries []LogEntry) error { - if len(entries) == 0 { - return fmt.Errorf("can't verify empty operation log") - } - - did := entries[0].DID - lvc := newLogValidationContext() - - for _, oe := range entries { - if oe.DID != did { - return fmt.Errorf("inconsistent DID") - } - // NOTE: we do not call oe.Validate() here because we'd end up verifying - // genesis op signatures twice. - // We check for CID consistency here, and will verify signatures (for all op types) later. - op := oe.Operation.AsOperation() - if op == nil { - return fmt.Errorf("invalid operation type") - } - if op.CID().String() != oe.CID { - return fmt.Errorf("inconsistent CID") - } - - datetime, err := syntax.ParseDatetime(oe.CreatedAt) - if err != nil { - return err - } - timestamp := datetime.Time() - - head, prevStatus, err := lvc.GetValidationContext(did, op.PrevCIDStr()) - if err != nil { - return err - } - - var allowedKeys *[]string - if op.IsGenesis() { - calcDid, err := op.DID() - if err != nil { - return err - } - if calcDid != did { - return fmt.Errorf("genesis DID does not match") - } - rotationKeys := op.EquivalentRotationKeys() - allowedKeys = &rotationKeys - } else { // not-genesis - allowedKeys = &prevStatus.AllowedKeys - } - keyIdx, err := VerifySignatureAny(op, *allowedKeys) - if err != nil { - return err - } - err = lvc.CommitValidOperation(did, head, prevStatus, op, timestamp, keyIdx) - if err != nil { - return err - } - } - - // check consistency of `nullified` fields - // Note: This has to be a separate loop because an op's eventual nullification status can't be known until after we've processed all operations - for idx, oe := range entries { - if idx == 0 { - if oe.Nullified { - return fmt.Errorf("genesis op cannot be nullified") - } - } - _, status, err := lvc.GetValidationContext(did, oe.CID) - if err != nil { - return err - } - if status.Nullified != oe.Nullified { - return fmt.Errorf("inconsistent nullification status for %s %s", did, oe.CID) - } - } - - return nil -} diff --git a/replica/database.go b/replica/database.go new file mode 100644 index 0000000..57f6fe9 --- /dev/null +++ b/replica/database.go @@ -0,0 +1,322 @@ +package replica + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "time" + + "github.com/did-method-plc/go-didplc/didplc" + slogGorm "github.com/orandin/slog-gorm" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Head represents the current head CID for a DID +type Head struct { + DID string `gorm:"column:did;primaryKey"` + CID string `gorm:"column:cid;not null"` +} + +// OperationRecord represents a stored operation with its status in the database +type OperationRecord struct { + DID string `gorm:"column:did;primaryKey;index:idx_operations_did_created_at,priority:1"` + CID string `gorm:"column:cid;primaryKey"` + CreatedAt time.Time `gorm:"column:created_at;not null;index:idx_operations_did_created_at,priority:2"` + Nullified bool `gorm:"column:nullified;not null;default:0"` + LastChild string `gorm:"column:last_child"` + AllowedKeysCount int `gorm:"column:allowed_keys_count;not null"` + OpData didplc.OpEnum `gorm:"column:op_data;not null"` +} + +// Note: couldn't call the type Operation because that'd get confusing with didplc.Operation +func (OperationRecord) TableName() string { + return "operations" +} + +// for tracking the ingest cursor +type HostCursor struct { + Host string `gorm:"primaryKey"` + Seq int64 `gorm:"not null"` +} + +// GormOpStore implements didplc.OpStore using a database backend +type GormOpStore struct { + db *gorm.DB +} + +var _ didplc.OpStore = (*GormOpStore)(nil) + +// NewGormOpStoreWithDialector creates a new database-backed operation store with a custom dialector +func NewGormOpStoreWithDialector(dialector gorm.Dialector, logger *slog.Logger) (*GormOpStore, error) { + db, err := gorm.Open(dialector, &gorm.Config{ + SkipDefaultTransaction: true, + //PrepareStmt: true, // Doesn't seem to work well with postgres + Logger: slogGorm.New( + slogGorm.WithHandler(logger.With("component", "opstore").Handler()), + slogGorm.WithTraceAll(), + slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug), + slogGorm.SetLogLevel(slogGorm.SlowQueryLogType, slog.LevelWarn), + slogGorm.SetLogLevel(slogGorm.ErrorLogType, slog.LevelError), + ), + }) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Configure connection pool + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get database handle: %w", err) + } + + sqlDB.SetMaxOpenConns(40) // with postgres, seems like less can be more... + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(time.Hour) + + // Auto-migrate the schema + if err := db.AutoMigrate(&Head{}, &OperationRecord{}, &HostCursor{}); err != nil { + return nil, fmt.Errorf("failed to migrate schema: %w", err) + } + + return &GormOpStore{ + db: db, + }, nil +} + +func NewGormOpStoreWithSqlite(dbPath string, logger *slog.Logger) (*GormOpStore, error) { + return NewGormOpStoreWithDialector( + sqlite.Open(dbPath+"?mode=rwc&cache=shared&_journal_mode=WAL"), + logger, + ) +} + +func NewGormOpStoreWithPostgres(dsn string, logger *slog.Logger) (*GormOpStore, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("failed to parse postgres URL: %w", err) + } + q := u.Query() + if !q.Has("synchronous_commit") { + // Since we're a replica, if we lose data we can just re-fetch it from the origin. + q.Set("synchronous_commit", "off") + } + u.RawQuery = q.Encode() + return NewGormOpStoreWithDialector( + postgres.Open(u.String()), + logger, + ) +} + +// GetLatest implements didplc.OpStore +func (db *GormOpStore) GetLatest(ctx context.Context, did string) (*didplc.OpEntry, error) { + var opRec OperationRecord + result := db.db.WithContext(ctx). + Joins("JOIN heads ON heads.did = operations.did AND heads.cid = operations.cid"). + Where("operations.did = ?", did). + Take(&opRec) + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + return nil, nil // DID not found + } + return nil, fmt.Errorf("database error: %w", result.Error) + } + + operation := opRec.OpData.AsOperation() + if operation == nil { + return nil, fmt.Errorf("invalid operation type") + } + + rotationKeys := operation.EquivalentRotationKeys() + allowedKeys := rotationKeys[:opRec.AllowedKeysCount] + + return &didplc.OpEntry{ + DID: opRec.DID, + CreatedAt: opRec.CreatedAt, + Nullified: opRec.Nullified, + LastChild: opRec.LastChild, + AllowedKeys: allowedKeys, + Op: operation, + OpCid: opRec.CID, + }, nil +} + +// GetEntry implements didplc.OpStore +func (db *GormOpStore) GetEntry(ctx context.Context, did string, cid string) (*didplc.OpEntry, error) { + var opRec OperationRecord + result := db.db.WithContext(ctx).Where("did = ? AND cid = ?", did, cid).Take(&opRec) + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("database error: %w", result.Error) + } + + // Get rotation keys from the operation + operation := opRec.OpData.AsOperation() + if operation == nil { + return nil, fmt.Errorf("invalid operation type") + } + + // Get rotation keys and slice to allowed count + rotationKeys := operation.EquivalentRotationKeys() + allowedKeys := rotationKeys[:opRec.AllowedKeysCount] + + return &didplc.OpEntry{ + DID: opRec.DID, + CreatedAt: opRec.CreatedAt, + Nullified: opRec.Nullified, + LastChild: opRec.LastChild, + AllowedKeys: allowedKeys, + Op: operation, + OpCid: cid, + }, nil +} + +// CommitOperations implements didplc.OpStore +func (db *GormOpStore) CommitOperations(ctx context.Context, ops []*didplc.PreparedOperation) error { + // Begin transaction + return db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, prepOp := range ops { + // Wrap the operation + opEnum, err := didplc.WrapOperation(prepOp.Op) + if err != nil { + return fmt.Errorf("failed to wrap operation: %w", err) + } + + if prepOp.PrevHead == "" { + // Genesis operation + // Insert new operation + newOp := OperationRecord{ + DID: prepOp.DID, + CID: prepOp.OpCid, + CreatedAt: prepOp.CreatedAt, + Nullified: false, + LastChild: "", + AllowedKeysCount: len(prepOp.Op.EquivalentRotationKeys()), + OpData: *opEnum, + } + if err := tx.Create(&newOp).Error; err != nil { + return fmt.Errorf("failed to create operation: %w", err) + } + + // Insert new head + newHead := Head{ + DID: prepOp.DID, + CID: prepOp.OpCid, + } + if err := tx.Create(&newHead).Error; err != nil { + return fmt.Errorf("failed to create head: %w", err) + } + } else { + // Non-genesis operation + // Mark nullified operations + for _, nullifiedCid := range prepOp.NullifiedOps { + if err := tx.Model(&OperationRecord{}).Where("did = ? AND cid = ?", prepOp.DID, nullifiedCid).Update("nullified", true).Error; err != nil { + return fmt.Errorf("failed to mark operation as nullified: %w", err) + } + } + + // Update previous operation's last_child and allowed_keys_count + if err := tx.Model(&OperationRecord{}).Where("did = ? AND cid = ?", prepOp.DID, prepOp.Op.PrevCIDStr()).Updates(map[string]interface{}{ + "last_child": prepOp.OpCid, + "allowed_keys_count": prepOp.KeyIndex, + }).Error; err != nil { + return fmt.Errorf("failed to update previous operation: %w", err) + } + + // Insert new operation + newOp := OperationRecord{ + DID: prepOp.DID, + CID: prepOp.OpCid, + CreatedAt: prepOp.CreatedAt, + Nullified: false, + LastChild: "", + AllowedKeysCount: len(prepOp.Op.EquivalentRotationKeys()), + OpData: *opEnum, + } + if err := tx.Create(&newOp).Error; err != nil { + return fmt.Errorf("failed to create operation: %w", err) + } + + // Update head with optimistic locking check + result := tx.Model(&Head{}).Where("did = ? AND cid = ?", prepOp.DID, prepOp.PrevHead).Update("cid", prepOp.OpCid) + if result.Error != nil { + return fmt.Errorf("failed to update head: %w", result.Error) + } else if result.RowsAffected != 1 { + return fmt.Errorf("head CID mismatch") + } + } + } + + return nil + }) +} + +// Not part of the OpStore interface, used to implement the GET /did/log endpoint +func (db *GormOpStore) GetOperationLog(ctx context.Context, did string) ([]*didplc.OpEnum, error) { + var opRecs []OperationRecord + + result := db.db.WithContext(ctx).Where("did = ?", did).Where("nullified = ?", false).Order("created_at ASC").Find(&opRecs) + if result.Error != nil { + return nil, fmt.Errorf("database error: %w", result.Error) + } + + operations := make([]*didplc.OpEnum, 0, len(opRecs)) + for _, opRec := range opRecs { + operations = append(operations, &opRec.OpData) + } + + return operations, nil +} + +// Not part of the OpStore interface, used to implement the GET /did/log/audit endpoint +func (db *GormOpStore) GetOperationLogAudit(ctx context.Context, did string) ([]*didplc.LogEntry, error) { + var opRecs []OperationRecord + + result := db.db.WithContext(ctx).Where("did = ?", did).Order("created_at ASC").Find(&opRecs) + if result.Error != nil { + return nil, fmt.Errorf("database error: %w", result.Error) + } + + entries := make([]*didplc.LogEntry, 0, len(opRecs)) + for _, opRec := range opRecs { + entry := &didplc.LogEntry{ + DID: opRec.DID, + Operation: opRec.OpData, + CID: opRec.CID, + Nullified: opRec.Nullified, + CreatedAt: opRec.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + } + entries = append(entries, entry) + } + + return entries, nil +} + +func (db *GormOpStore) PutCursor(ctx context.Context, host string, seq int64) error { + // upsert + result := db.db.WithContext(ctx).Clauses(clause.OnConflict{ + UpdateAll: true, + }).Create(&HostCursor{ + Host: host, + Seq: seq, + }) + return result.Error +} + +// returns 0 if not found (since new hosts should start from 0) +func (db *GormOpStore) GetCursor(ctx context.Context, host string) (int64, error) { + var hostCursor HostCursor + result := db.db.WithContext(ctx).Where("host = ?", host).Take(&hostCursor) + if result.Error == gorm.ErrRecordNotFound { + return 0, nil + } + if result.Error != nil { + return 0, result.Error + } + return hostCursor.Seq, nil +} diff --git a/replica/inflight.go b/replica/inflight.go new file mode 100644 index 0000000..d069e1c --- /dev/null +++ b/replica/inflight.go @@ -0,0 +1,102 @@ +package replica + +import ( + "sync" + + "github.com/emirpasic/gods/sets/hashset" + "github.com/emirpasic/gods/sets/treeset" +) + +/* + +Constraints: + +- AddInFlight is always called in order of ascending seq + +*/ + +type InFlight struct { + resumeCursor int64 // all seqs <= this value have already been processed and committed to db + dids *hashset.Set + seqs *treeset.Set // treeset means we can find the minimum efficiently + removed *treeset.Set // seqs that have been removed but are ahead of the cursor + lock sync.RWMutex +} + +func int64Comparator(a, b interface{}) int { + aInt := a.(int64) + bInt := b.(int64) + if aInt < bInt { + return -1 + } else if aInt > bInt { + return 1 + } + return 0 +} + +func NewInFlight(resumeCursor int64) *InFlight { + return &InFlight{ + resumeCursor: resumeCursor, + dids: hashset.New(), + seqs: treeset.NewWith(int64Comparator), + removed: treeset.NewWith(int64Comparator), + } +} + +func (infl *InFlight) GetResumeCursor() int64 { + infl.lock.RLock() + defer infl.lock.RUnlock() + return infl.resumeCursor +} + +// returns true on success, does nothing and returns false if the DID was already in-flight +func (infl *InFlight) AddInFlight(did string, seq int64) bool { + infl.lock.Lock() + defer infl.lock.Unlock() + + if infl.dids.Contains(did) { + return false + } + + infl.dids.Add(did) + infl.seqs.Add(seq) + + return true +} + +// always succeeds, and updates resumeCursor if appropriate +func (infl *InFlight) RemoveInFlight(did string, seq int64) { + infl.lock.Lock() + defer infl.lock.Unlock() + + // just for extra safety, do nothing if it's already been removed + if !infl.dids.Contains(did) { + // if you reached here you're using the API wrong + return + } + + infl.dids.Remove(did) + infl.seqs.Remove(seq) + infl.removed.Add(seq) + + // drain: advance cursor past completed seqs below the lowest inflight + for { + it := infl.removed.Iterator() + if !it.First() { + break + } + minRemoved := it.Value().(int64) + + inflIt := infl.seqs.Iterator() + if inflIt.First() { + minInflight := inflIt.Value().(int64) + if minRemoved >= minInflight { + break + } + } + + // minRemoved is below all inflight items (or inflight is empty), advance cursor + infl.resumeCursor = minRemoved + infl.removed.Remove(minRemoved) + } +} diff --git a/replica/inflight_test.go b/replica/inflight_test.go new file mode 100644 index 0000000..33dca28 --- /dev/null +++ b/replica/inflight_test.go @@ -0,0 +1,169 @@ +package replica + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddInFlight_Success(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + added := infl.AddInFlight("did:plc:test123", 100) + assert.True(added, "first add should succeed") +} + +func TestAddInFlight_DuplicateDID(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + added1 := infl.AddInFlight("did:plc:test123", 100) + assert.True(added1, "first add should succeed") + + added2 := infl.AddInFlight("did:plc:test123", 200) + assert.False(added2, "adding same DID twice should return false") +} + +func TestAddInFlight_MultipleDIDs(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + added1 := infl.AddInFlight("did:plc:test1", 100) + assert.True(added1) + + added2 := infl.AddInFlight("did:plc:test2", 200) + assert.True(added2) + + added3 := infl.AddInFlight("did:plc:test3", 150) + assert.True(added3) +} + +func TestRemoveInFlight_TracksResumeCursor(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + infl.AddInFlight("did:plc:test3", 300) + infl.AddInFlight("did:plc:test4", 400) + infl.AddInFlight("did:plc:test5", 500) + + assert.Equal(infl.GetResumeCursor(), int64(-1)) + + infl.RemoveInFlight("did:plc:test2", 200) + + assert.Equal(infl.GetResumeCursor(), int64(-1)) + + infl.RemoveInFlight("did:plc:test4", 400) + + assert.Equal(infl.GetResumeCursor(), int64(-1)) + + infl.RemoveInFlight("did:plc:test1", 100) + + assert.Equal(infl.GetResumeCursor(), int64(200)) + + infl.RemoveInFlight("did:plc:test3", 300) + + assert.Equal(infl.GetResumeCursor(), int64(400)) +} + +func TestRemoveInFlight_RemoveAll(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + infl.AddInFlight("did:plc:test3", 300) + + infl.RemoveInFlight("did:plc:test2", 200) + infl.RemoveInFlight("did:plc:test1", 100) + assert.Equal(int64(200), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test3", 300) + assert.Equal(int64(300), infl.GetResumeCursor()) +} + +func TestRemoveInFlight_ReverseOrder(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + infl.AddInFlight("did:plc:test3", 300) + infl.AddInFlight("did:plc:test4", 400) + + infl.RemoveInFlight("did:plc:test4", 400) + assert.Equal(int64(-1), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test3", 300) + assert.Equal(int64(-1), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test2", 200) + assert.Equal(int64(-1), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test1", 100) + assert.Equal(int64(400), infl.GetResumeCursor()) +} + +func TestRemoveInFlight_DoubleRemove(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + infl.AddInFlight("did:plc:test3", 300) + + infl.RemoveInFlight("did:plc:test1", 100) + infl.RemoveInFlight("did:plc:test2", 200) + assert.Equal(int64(200), infl.GetResumeCursor()) + + // double remove should not regress cursor + infl.RemoveInFlight("did:plc:test1", 100) + assert.Equal(int64(200), infl.GetResumeCursor()) +} + +func TestRemoveInFlight_InterleavedAddsAndRemoves(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + + infl.RemoveInFlight("did:plc:test1", 100) + assert.Equal(int64(100), infl.GetResumeCursor()) + + infl.AddInFlight("did:plc:test3", 300) + infl.AddInFlight("did:plc:test4", 400) + + infl.RemoveInFlight("did:plc:test4", 400) + assert.Equal(int64(100), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test2", 200) + assert.Equal(int64(200), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test3", 300) + assert.Equal(int64(400), infl.GetResumeCursor()) +} + +func TestRemoveInFlight_AllowsReAdd(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + added1 := infl.AddInFlight("did:plc:test1", 100) + assert.True(added1) + + infl.RemoveInFlight("did:plc:test1", 100) + + added2 := infl.AddInFlight("did:plc:test1", 200) + assert.True(added2, "should allow re-adding DID after removal") +} diff --git a/replica/ingest.go b/replica/ingest.go new file mode 100644 index 0000000..f0eba11 --- /dev/null +++ b/replica/ingest.go @@ -0,0 +1,465 @@ +package replica + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "time" + + "github.com/carlmjohnson/versioninfo" + "github.com/did-method-plc/go-didplc/didplc" + "github.com/gorilla/websocket" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// ExportEntry represents a single entry from the /export endpoint, which includes +// the LogEntry fields plus a sequence number. +type ExportEntry struct { + DID string `json:"did"` + CID string `json:"cid"` + Seq int64 `json:"seq"` + CreatedAt string `json:"createdAt"` + Operation didplc.OpEnum `json:"operation"` + Type string `json:"type"` + Nullified bool `json:"nullified,omitempty"` +} + +// toSequencedOp converts an ExportEntry into a SequencedOp, parsing the +// timestamp and resolving the concrete operation type. Returns nil if the +// entry should be skipped (non-sequenced_op type or invalid operation). +func (e *ExportEntry) toSequencedOp(logger *slog.Logger) (*SequencedOp, error) { + if e.Type != "sequenced_op" { + logger.Warn("skipping entry with unexpected type", "type", e.Type) + return nil, nil + } + + op := e.Operation.AsOperation() + if op == nil { + return nil, nil + } + + createdAt, err := time.Parse(time.RFC3339, e.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to parse timestamp for %s: %w", e.DID, err) + } + + return &SequencedOp{ + DID: e.DID, + CID: e.CID, + Operation: op, + CreatedAt: createdAt, + Seq: e.Seq, + }, nil +} + +const ( + // caughtUpThreshold is how close to real-time the latest entry must be + // before ingestPaginated switches to streaming. + caughtUpThreshold = 1 * time.Hour + + // retryDelay is the delay before retrying after an ingestion error. + retryDelay = 1 * time.Second + + // cursorPersistInterval is how often the resume cursor is persisted. + cursorPersistInterval = 1 * time.Second + + // If this timeout is reached, we'll retry the request. + // Also used as the timeout for websocket reads, triggering a reconnect. + httpClientTimeout = 30 * time.Second +) + +var ( + // errOutdatedCursor is returned by ingestStream when the server sends an + // OutdatedCursor close reason, indicating the cursor is too old for the + // streaming endpoint and paginated catch-up is needed. + errOutdatedCursor = errors.New("outdated cursor") + + // errCaughtUp is returned by ingestPaginated when the latest entry + // timestamp is within 1 hour of now, indicating we're close enough to + // real-time to switch to streaming. + errCaughtUp = errors.New("caught up to near real-time") +) + +// Ingestor streams operations from a PLC directory export endpoint, +// validates them, and commits them to the local store. +type Ingestor struct { + store *GormOpStore + directoryURL string + parsedDirectoryURL *url.URL + numWorkers int + startCursor int64 + userAgent string + httpClient *http.Client + wsDialer *websocket.Dialer + logger *slog.Logger +} + +// NewIngestor creates a new Ingestor. Pass startCursor == -1 to resume from +// the cursor stored in the database. +func NewIngestor(store *GormOpStore, directoryURL string, startCursor int64, numWorkers int, logger *slog.Logger) (*Ingestor, error) { + parsedDirectoryURL, err := url.Parse(directoryURL) + if err != nil { + return nil, err + } + return &Ingestor{ + store: store, + directoryURL: directoryURL, + parsedDirectoryURL: parsedDirectoryURL, + numWorkers: numWorkers, + startCursor: startCursor, + userAgent: fmt.Sprintf("go-didplc-replica/%s", versioninfo.Short()), + httpClient: &http.Client{ + Timeout: httpClientTimeout, + Transport: otelhttp.NewTransport(http.DefaultTransport), + }, + wsDialer: websocket.DefaultDialer, + logger: logger.With("component", "ingestor"), + }, nil +} + +// Run executes the full ingestion pipeline: resolving the cursor, spawning +// validate/commit workers, streaming operations from the directory, and +// dispatching them through the pipeline. +func (i *Ingestor) Run(ctx context.Context) error { + cursor := i.startCursor + if cursor == -1 { + var err error + cursor, err = i.store.GetCursor(ctx, i.directoryURL) + if err != nil { + return err + } + } + + infl := NewInFlight(cursor) + + /* + + ingest reads operations from the upstream PLC directory, and puts them into + the ingestedOps channel (in seq order). + + one of the loops below reads from ingestedOps and forwards them into seqops, *but*, importantly, + it ensures that there are never two operations for the same DID in-flight at once. + + ValidateWorker threads each sit in a loop reading from seqops, validating operations, and + writing the validated ops into the validatedOps channel. + + Finally, the CommitWorker loop reads from validatedOps and commits them to the db in batches. + + */ + + ingestedOps := make(chan *SequencedOp, 10000) + seqops := make(chan *SequencedOp, 100) + validatedOps := make(chan ValidatedOp, 1000) + + // Start multiple validateWorker goroutines + for range i.numWorkers { + go ValidateWorker(ctx, seqops, validatedOps, infl, i.store) + } + + // Start single commit worker + flushCh := make(chan chan struct{}) + go CommitWorker(ctx, validatedOps, infl, i.store, flushCh) + + // Periodically persist the resume cursor and record queue metrics + go func() { + ticker := time.NewTicker(cursorPersistInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + resumeCursor := infl.GetResumeCursor() + if err := i.store.PutCursor(ctx, i.directoryURL, resumeCursor); err != nil { + i.logger.Error("failed to persist cursor", "error", err) + } else { + i.logger.Info("persisted cursor", "cursor", resumeCursor, "host", i.directoryURL) + } + IngestCursorGauge.Record(ctx, resumeCursor) + IngestedOpsQueueGauge.Record(ctx, int64(len(ingestedOps))) + SeqOpsQueueGauge.Record(ctx, int64(len(seqops))) + ValidatedOpsQueueGauge.Record(ctx, int64(len(validatedOps))) + } + } + }() + + // Start ingestion state machine in a goroutine + go i.ingestLoop(ctx, &cursor, ingestedOps) + + // Process operations from ingestion channel and add to InFlight before sending to workers + for seqop := range ingestedOps { + + // If the DID is already in-flight, ask the committer to flush its + // batch so the previous op for this DID hopefully gets committed and removed + // from in-flight tracking. (it might still be in a queue but we'll get there eventually) + for !infl.AddInFlight(seqop.DID, seqop.Seq) { + done := make(chan struct{}) + flushCh <- done + <-done + } + + seqops <- seqop + + // Note: we're recording this timestamp when the op is in-flight, not yet validated/committed + LastIngestedOpTsGauge.Record(ctx, seqop.CreatedAt.Unix()) + } + + return nil +} + +// ingestLoop is the state machine that orchestrates ingestion, switching between +// websocket streaming (/export/stream) and paginated HTTP (/export) as needed. +// +// It starts by attempting a websocket stream. If the server reports an outdated +// cursor, it falls back to paginated ingestion until caught up, then switches +// back to streaming. Other errors trigger a retry after a fixed delay. +func (i *Ingestor) ingestLoop(ctx context.Context, cursor *int64, ops chan<- *SequencedOp) { + recordState := func(attr attribute.KeyValue) { + // Record 1 for the active state, 0 for the other + if attr == IngestStateStream { + IngestStateGauge.Record(ctx, 1, metric.WithAttributes(IngestStateStream)) + IngestStateGauge.Record(ctx, 0, metric.WithAttributes(IngestStatePaginated)) + } else { + IngestStateGauge.Record(ctx, 1, metric.WithAttributes(IngestStatePaginated)) + IngestStateGauge.Record(ctx, 0, metric.WithAttributes(IngestStateStream)) + } + } + + for { + recordState(IngestStateStream) + i.logger.Info("starting stream ingestion", "cursor", *cursor) + err := i.ingestStream(ctx, cursor, ops) + if err == nil { + continue + } + + if errors.Is(err, errOutdatedCursor) { + i.logger.Info("cursor outdated for stream, falling back to paginated", "cursor", *cursor) + recordState(IngestStatePaginated) + for { + i.logger.Info("starting paginated ingestion", "cursor", *cursor) + perr := i.ingestPaginated(ctx, cursor, ops) + if perr == nil { + continue + } + if errors.Is(perr, errCaughtUp) { + i.logger.Info("caught up, switching to stream", "cursor", *cursor) + break // back to outer loop -> try stream again + } + i.logger.Error("paginated ingestion error, retrying", "error", perr) + if !sleepCtx(ctx, retryDelay) { + return + } + } + continue + } + + if ctx.Err() != nil { + return + } + + i.logger.Error("stream ingestion error, retrying", "error", err) + if !sleepCtx(ctx, retryDelay) { + return + } + } +} + +// ingestStream connects to the /export/stream websocket endpoint and reads +// operations until an error occurs. Returns errOutdatedCursor if the server +// closes the connection with an OutdatedCursor reason. +func (i *Ingestor) ingestStream(ctx context.Context, cursor *int64, ops chan<- *SequencedOp) error { + wsURL := buildStreamURL(i.parsedDirectoryURL, *cursor) + i.logger.Debug("websocket connecting", "url", wsURL) + + header := http.Header{} + header.Set("User-Agent", i.userAgent) + + conn, _, err := i.wsDialer.Dial(wsURL, header) + if err != nil { + return fmt.Errorf("websocket dial failed: %w", err) + } + + // Close the connection when ctx is cancelled. ReadMessage doesn't accept + // a context, so we need this goroutine to interrupt it. + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + conn.Close() + case <-done: + } + }() + defer close(done) + defer conn.Close() + + i.logger.Info("websocket connected", "url", wsURL) + + for { + conn.SetReadDeadline(time.Now().Add(httpClientTimeout)) + _, msg, err := conn.ReadMessage() + if err != nil { + // Check for OutdatedCursor close reason + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) && closeErr.Text == "OutdatedCursor" { + return errOutdatedCursor + } + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("websocket read error: %w", err) + } + + var entry ExportEntry + if err := json.Unmarshal(msg, &entry); err != nil { + return fmt.Errorf("failed to parse websocket message: %w", err) + } + + seqop, err := entry.toSequencedOp(i.logger) + if err != nil { + return err + } + if seqop == nil { + continue + } + + select { + case ops <- seqop: + case <-ctx.Done(): + return ctx.Err() + } + + if entry.Seq > *cursor { + *cursor = entry.Seq + } + } +} + +// ingestPaginated fetches operations from the paginated /export HTTP endpoint. +// It loops through pages until it encounters an error or determines the cursor +// is within 1 hour of real-time (returns errCaughtUp). +func (i *Ingestor) ingestPaginated(ctx context.Context, cursor *int64, ops chan<- *SequencedOp) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + reqURL := fmt.Sprintf("%s/export?after=%d", i.directoryURL, *cursor) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", i.userAgent) + + i.logger.Debug("http request starting", "method", "GET", "url", reqURL) + resp, err := i.httpClient.Do(req) + if err != nil { + i.logger.Error("http request failed", "error", err) + return fmt.Errorf("failed to fetch export: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + i.logger.Error("http request failed", "status", resp.StatusCode) + return fmt.Errorf("export endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var latestCreatedAt time.Time + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(nil, 10000000) // Set a reasonable max size for large operations + + for scanner.Scan() { + select { + case <-ctx.Done(): + resp.Body.Close() + return ctx.Err() + default: + } + + var entry ExportEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + resp.Body.Close() + i.logger.Error("http request failed", "error", "JSON parse error", "details", err) + return fmt.Errorf("failed to parse export entry: %w", err) + } + + seqop, err := entry.toSequencedOp(i.logger) + if err != nil { + resp.Body.Close() + return err + } + if seqop == nil { + continue + } + + select { + case ops <- seqop: + case <-ctx.Done(): + resp.Body.Close() + return ctx.Err() + } + + if entry.Seq > *cursor { + *cursor = entry.Seq + } + if seqop.CreatedAt.After(latestCreatedAt) { + latestCreatedAt = seqop.CreatedAt + } + } + + if err := scanner.Err(); err != nil { + resp.Body.Close() + i.logger.Error("http request failed", "error", "stream read error", "details", err) + return fmt.Errorf("error reading export stream: %w", err) + } + + resp.Body.Close() + + // Check if we're close enough to real-time to switch to streaming + if !latestCreatedAt.IsZero() && time.Since(latestCreatedAt) < caughtUpThreshold { + return errCaughtUp + } + } +} + +// buildStreamURL converts an HTTP directory URL to a websocket /export/stream URL. +// e.g. "https://host" -> "wss://host/export/stream?cursor=N" +func buildStreamURL(u *url.URL, cursor int64) string { + copy := *u + + switch copy.Scheme { + case "https": + copy.Scheme = "wss" + case "http": + copy.Scheme = "ws" + } + + copy.Path = "/export/stream" + q := copy.Query() + q.Set("cursor", fmt.Sprintf("%d", cursor)) + copy.RawQuery = q.Encode() + return copy.String() +} + +// sleepCtx sleeps for the given duration or until the context is cancelled. +// Returns true if the sleep completed, false if the context was cancelled. +func sleepCtx(ctx context.Context, d time.Duration) bool { + select { + case <-time.After(d): + return true + case <-ctx.Done(): + return false + } +} diff --git a/replica/metrics.go b/replica/metrics.go new file mode 100644 index 0000000..71f3e34 --- /dev/null +++ b/replica/metrics.go @@ -0,0 +1,64 @@ +package replica + +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +var meter = otel.Meter("github.com/did-method-plc/go-didplc/replica") + +var ( + IngestCursorGauge metric.Int64Gauge + IngestedOpsQueueGauge metric.Int64Gauge + SeqOpsQueueGauge metric.Int64Gauge + ValidatedOpsQueueGauge metric.Int64Gauge + IngestStateGauge metric.Int64Gauge + LastIngestedOpTsGauge metric.Int64Gauge +) + +var ( + IngestStateStream = attribute.String("state", "stream") + IngestStatePaginated = attribute.String("state", "paginated") +) + +func init() { + var err error + IngestCursorGauge, err = meter.Int64Gauge("plc_replica_ingest_cursor", + metric.WithDescription("The most recently committed seq value"), + ) + if err != nil { + panic(err) + } + IngestedOpsQueueGauge, err = meter.Int64Gauge("plc_replica_ingested_ops_queue", + metric.WithDescription("Number of items in the ingested ops channel"), + ) + if err != nil { + panic(err) + } + SeqOpsQueueGauge, err = meter.Int64Gauge("plc_replica_seq_ops_queue", + metric.WithDescription("Number of items in the sequenced ops channel"), + ) + if err != nil { + panic(err) + } + ValidatedOpsQueueGauge, err = meter.Int64Gauge("plc_replica_validated_ops_queue", + metric.WithDescription("Number of items in the validated ops channel"), + ) + if err != nil { + panic(err) + } + IngestStateGauge, err = meter.Int64Gauge("plc_replica_ingest_state", + metric.WithDescription("Current ingest mode: 1 with state attribute (stream or paginated)"), + ) + if err != nil { + panic(err) + } + LastIngestedOpTsGauge, err = meter.Int64Gauge("plc_replica_last_ingested_op_ts", + metric.WithDescription("Unix timestamp of the most recently ingested operation"), + metric.WithUnit("s"), + ) + if err != nil { + panic(err) + } +} diff --git a/replica/server.go b/replica/server.go new file mode 100644 index 0000000..4b1230d --- /dev/null +++ b/replica/server.go @@ -0,0 +1,229 @@ +package replica + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "github.com/carlmjohnson/versioninfo" + "github.com/did-method-plc/go-didplc/didplc" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +// DIDDataResponse is the response for GET /{did}/data +type DIDDataResponse struct { + DID string `json:"did"` + VerificationMethods map[string]string `json:"verificationMethods"` + RotationKeys []string `json:"rotationKeys"` + AlsoKnownAs []string `json:"alsoKnownAs"` + Services map[string]didplc.OpService `json:"services"` +} + +// Server holds the HTTP server and its dependencies +type Server struct { + store *GormOpStore + addr string + logger *slog.Logger +} + +// NewServer creates a new HTTP server +func NewServer(store *GormOpStore, addr string, logger *slog.Logger) *Server { + return &Server{ + store: store, + addr: addr, + logger: logger.With("component", "server"), + } +} + +// Run starts the HTTP server (blocking) +func (s *Server) Run() error { + mux := http.NewServeMux() + mux.HandleFunc("GET /_health", s.handleHealth) + mux.HandleFunc("GET /{did}/log/audit", s.handleDIDLogAudit) + mux.HandleFunc("GET /{did}/log/last", s.handleDIDLogLast) + mux.HandleFunc("GET /{did}/log", s.handleDIDLog) + mux.HandleFunc("GET /{did}/data", s.handleDIDData) + mux.HandleFunc("GET /{did}", s.handleDIDDoc) + mux.HandleFunc("GET /{$}", s.handleIndex) + + handler := otelhttp.NewHandler(mux, "") + + s.logger.Info("http server listening", "addr", s.addr) + return http.ListenAndServe(s.addr, handler) +} + +// handleIndex serves the index page +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "hello plc replica\n") +} + +// handleHealth handles GET /_health - returns version information +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "version": versioninfo.Short(), + }) +} + +// writeJSONError writes a JSON error response +func writeJSONError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"message": message}) +} + +// handleDIDDoc handles GET /{did} - returns the DID document +func (s *Server) handleDIDDoc(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + head, err := s.store.GetLatest(ctx, did) + if err != nil { + writeJSONError(w, fmt.Sprintf("error fetching head: %v", err), http.StatusInternalServerError) + return + } + if head == nil { + writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + // Generate DID document + doc, err := head.Op.Doc(did) + if err != nil { + writeJSONError(w, fmt.Sprintf("error generating DID document: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/did+json") + if err := json.NewEncoder(w).Encode(doc); err != nil { + writeJSONError(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) + return + } +} + +// handleDIDData handles GET /{did}/data - returns the latest operation data +func (s *Server) handleDIDData(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + head, err := s.store.GetLatest(ctx, did) + if err != nil { + writeJSONError(w, fmt.Sprintf("error fetching head: %v", err), http.StatusInternalServerError) + return + } + if head == nil { + writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + // Build response based on operation type + var resp DIDDataResponse + resp.DID = did + + switch v := head.Op.(type) { + case *didplc.RegularOp: + resp.RotationKeys = v.RotationKeys + resp.VerificationMethods = v.VerificationMethods + resp.AlsoKnownAs = v.AlsoKnownAs + resp.Services = v.Services + case *didplc.LegacyOp: + // Convert legacy op to regular op format + regular := v.RegularOp() + resp.RotationKeys = regular.RotationKeys + resp.VerificationMethods = regular.VerificationMethods + resp.AlsoKnownAs = regular.AlsoKnownAs + resp.Services = regular.Services + case *didplc.TombstoneOp: + writeJSONError(w, fmt.Sprintf("DID not available: %s", did), http.StatusNotFound) + return + default: + writeJSONError(w, "unknown operation type", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + writeJSONError(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) + return + } +} + +// handleDIDLogAudit handles GET /{did}/log/audit - returns the full audit log with metadata +func (s *Server) handleDIDLogAudit(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + // Get the audit log (including nullified operations and metadata) + entries, err := s.store.GetOperationLogAudit(ctx, did) + if err != nil { + writeJSONError(w, fmt.Sprintf("error fetching audit log: %v", err), http.StatusInternalServerError) + return + } + + if len(entries) == 0 { + writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(entries); err != nil { + writeJSONError(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) + return + } +} + +// handleDIDLog handles GET /{did}/log - returns the full operation log +func (s *Server) handleDIDLog(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + // Get the operation log (excluding nullified operations) + operations, err := s.store.GetOperationLog(ctx, did) + if err != nil { + writeJSONError(w, fmt.Sprintf("error fetching operation log: %v", err), http.StatusInternalServerError) + return + } + + if len(operations) == 0 { + writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(operations); err != nil { + writeJSONError(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) + return + } +} + +// handleDIDLogLast handles GET /{did}/log/last - returns the raw last operation +func (s *Server) handleDIDLogLast(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + // Get the head CID for this DID + head, err := s.store.GetLatest(ctx, did) + if err != nil { + writeJSONError(w, fmt.Sprintf("error fetching head: %v", err), http.StatusInternalServerError) + return + } + if head == nil { + writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + // Wrap the operation in OpEnum for proper JSON marshaling + opEnum, err := didplc.WrapOperation(head.Op) + if err != nil { + writeJSONError(w, fmt.Sprintf("error wrapping operation: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(opEnum); err != nil { + writeJSONError(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) + return + } +} diff --git a/replica/server_test.go b/replica/server_test.go new file mode 100644 index 0000000..f5250f3 --- /dev/null +++ b/replica/server_test.go @@ -0,0 +1,336 @@ +package replica + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/did-method-plc/go-didplc/didplc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" +) + +func newTestServer(t *testing.T) (http.Handler, *GormOpStore) { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + store, err := NewGormOpStoreWithDialector(sqlite.Open(":memory:"), logger) + require.NoError(t, err) + sqlDB, err := store.db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { sqlDB.Close() }) + + s := NewServer(store, ":0", logger) + mux := http.NewServeMux() + mux.HandleFunc("GET /{did}/log/audit", s.handleDIDLogAudit) + mux.HandleFunc("GET /{did}/log/last", s.handleDIDLogLast) + mux.HandleFunc("GET /{did}/log", s.handleDIDLog) + mux.HandleFunc("GET /{did}/data", s.handleDIDData) + mux.HandleFunc("GET /{did}", s.handleDIDDoc) + mux.HandleFunc("GET /{$}", s.handleIndex) + return mux, store +} + +func TestHandleIndex(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/plain", w.Header().Get("Content-Type")) + assert.NotEmpty(t, w.Body.String()) +} + +func TestHandleDIDDoc(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + commitGenesis(t, ctx, store, genesis, did, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did, nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/did+json", w.Header().Get("Content-Type")) + + var doc didplc.Doc + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &doc)) + assert.Equal(t, did, doc.ID) + assert.Contains(t, doc.AlsoKnownAs, "at://test.example.com") + assert.NotEmpty(t, doc.VerificationMethod) + assert.NotEmpty(t, doc.Service) +} + +func TestHandleDIDDoc_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) +} + +func TestHandleDIDData(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + commitGenesis(t, ctx, store, genesis, did, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/data", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var resp DIDDataResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, did, resp.DID) + assert.Equal(t, []string{pubKey}, resp.RotationKeys) + assert.Contains(t, resp.AlsoKnownAs, "at://test.example.com") + assert.Contains(t, resp.Services, "atproto_pds") +} + +func TestHandleDIDData_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent/data", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleDIDData_Tombstone(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + tombstone := &didplc.TombstoneOp{Type: "plc_tombstone", Prev: genesisCID} + require.NoError(t, tombstone.Sign(priv)) + prepOp, _, err := didplc.VerifyOperation(ctx, store, did, tombstone, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/data", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleDIDData_AfterUpdate(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + prepOp, _, err := didplc.VerifyOperation(ctx, store, did, update, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/data", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp DIDDataResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, did, resp.DID) + assert.Contains(t, resp.AlsoKnownAs, "at://updated.example.com") +} + +func TestHandleDIDLog(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + prepOp, _, err := didplc.VerifyOperation(ctx, store, did, update, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var ops []didplc.OpEnum + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &ops)) + assert.Len(t, ops, 2) +} + +func TestHandleDIDLog_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent/log", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleDIDLog_ExcludesNullified(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Regular update signed by key at index 1 + update := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, _, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // Nullification signed by recovery key (prev = genesis, not update) + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + t2 := t1.Add(time.Hour) + prepOp2, _, err := didplc.VerifyOperation(ctx, store, did, nullify, t2) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp2})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + + var ops []didplc.OpEnum + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &ops)) + assert.Len(t, ops, 2, "should have genesis + nullification, excluding the nullified update") +} + +func TestHandleDIDLogAudit(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Regular update signed by key at index 1 + update := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, _, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // Nullification signed by recovery key + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + t2 := t1.Add(time.Hour) + prepOp2, _, err := didplc.VerifyOperation(ctx, store, did, nullify, t2) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp2})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log/audit", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var entries []didplc.LogEntry + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &entries)) + assert.Len(t, entries, 3, "audit log includes all ops including nullified") + + nullifiedCount := 0 + for _, e := range entries { + if e.Nullified { + nullifiedCount++ + } + } + assert.Equal(t, 1, nullifiedCount) +} + +func TestHandleDIDLogAudit_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent/log/audit", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleDIDLogLast(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + prepOp, _, err := didplc.VerifyOperation(ctx, store, did, update, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log/last", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var opEnum didplc.OpEnum + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &opEnum)) + assert.NotNil(t, opEnum.Regular) + assert.NotNil(t, opEnum.Regular.Prev, "last op should be the update, not genesis") +} + +func TestHandleDIDLogLast_GenesisOnly(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + commitGenesis(t, ctx, store, genesis, did, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log/last", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + + var opEnum didplc.OpEnum + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &opEnum)) + assert.NotNil(t, opEnum.Regular) + assert.Nil(t, opEnum.Regular.Prev, "genesis op has no prev") +} + +func TestHandleDIDLogLast_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent/log/last", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/replica/validate.go b/replica/validate.go new file mode 100644 index 0000000..37364e7 --- /dev/null +++ b/replica/validate.go @@ -0,0 +1,164 @@ +package replica + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/did-method-plc/go-didplc/didplc" +) + +type SequencedOp struct { + DID string + CID string + Operation didplc.Operation + CreatedAt time.Time + Seq int64 +} + +const batchSize = 1000 + +type ValidatedOp struct { + Seq int64 + PrepOp *didplc.PreparedOperation +} + +// ValidateWorker validates operations from seqops channel and sends validated +// operations to validatedOps channel. Multiple workers can run in parallel. +// Note: caller is responsible for inserting into inflight, but we are responsible for removal on validation failure +func ValidateWorker(ctx context.Context, seqops chan *SequencedOp, validatedOps chan<- ValidatedOp, infl *InFlight, store didplc.OpStore) { + for { + select { + case <-ctx.Done(): + return + case seqop, ok := <-seqops: + if !ok { + return + } + + prepOp, err := validateInner(ctx, seqop, store) + if err != nil { + // Validation failed - remove from InFlight and skip + slog.Warn("validation failed", "did", seqop.DID, "seq", seqop.Seq, "cid", seqop.CID, "error", err) + infl.RemoveInFlight(seqop.DID, seqop.Seq) + continue + } + + // Send validated operation to commit worker + validatedOps <- ValidatedOp{ + Seq: seqop.Seq, + PrepOp: prepOp, + } + } + } +} + +// CommitWorker receives validated operations and commits them to the database in batches. +// Only a single commit worker should run to avoid database contention. +// Note: responsible for removing from InFlight after commit +func CommitWorker(ctx context.Context, validatedOps <-chan ValidatedOp, infl *InFlight, store didplc.OpStore, flushCh <-chan chan struct{}) { + batch := make([]ValidatedOp, 0, batchSize) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + commitBatch := func() { + if len(batch) == 0 { + return + } + + // Extract PreparedOperations for commit + prepOps := make([]*didplc.PreparedOperation, len(batch)) + for i, vop := range batch { + prepOps[i] = vop.PrepOp + } + + // Commit the batch + for { + err := store.CommitOperations(ctx, prepOps) + if err == nil { + break + } + slog.Error("failed to commit batch", "batch_size", len(batch), "error", err) + + // This is pretty bad. If it's a transient db issue, hopefully we can retry. + // If it's some other kind of failure... we're stuck here forever. But at least the server can stay up. + + // TODO: try committing each element of the batch individually, to limit the blast radius. + + if !sleepCtx(ctx, 1*time.Second) { + return + } + } + + // Remove all from InFlight + for _, vop := range batch { + infl.RemoveInFlight(vop.PrepOp.DID, vop.Seq) + } + + // Clear the batch + batch = batch[:0] + } + + for { + select { + case <-ctx.Done(): + commitBatch() + return + case vop, ok := <-validatedOps: + if !ok { + // Channel closed, commit remaining and exit + commitBatch() + return + } + + // Add to batch + batch = append(batch, vop) + + // Commit if batch is full + if len(batch) >= batchSize { + commitBatch() + } + + case <-ticker.C: + // Periodically flush partial batches to prevent deadlock + commitBatch() + + case done := <-flushCh: + commitBatch() + close(done) + } + } +} + +func validateInner(ctx context.Context, seqop *SequencedOp, store didplc.OpStore) (*didplc.PreparedOperation, error) { + var prepOp *didplc.PreparedOperation + var opIsInvalid bool + var err error + + for { + prepOp, opIsInvalid, err = didplc.VerifyOperation(ctx, store, seqop.DID, seqop.Operation, seqop.CreatedAt) + if err != nil { + if opIsInvalid { + // Operation is definitely invalid - don't retry + return nil, fmt.Errorf("failed verifying op %s, %s: %w", seqop.DID, seqop.CID, err) + } + + // Transient error (hopefully) - retry with sleep. + // If the db is down then waiting for it to come back is all we can do. + slog.Warn("failed verifying op, retrying", "did", seqop.DID, "cid", seqop.CID, "error", err) + if !sleepCtx(ctx, 1*time.Second) { + return nil, fmt.Errorf("context cancelled while retrying verification: %w", err) + } + continue + } + + break // success + } + + if prepOp.OpCid != seqop.CID { + return nil, fmt.Errorf("inconsistent CID for %s %s", seqop.DID, seqop.CID) + } + + return prepOp, nil +} diff --git a/replica/validate_test.go b/replica/validate_test.go new file mode 100644 index 0000000..e410e57 --- /dev/null +++ b/replica/validate_test.go @@ -0,0 +1,620 @@ +package replica + +import ( + "context" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/did-method-plc/go-didplc/didplc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helper: generate a key pair and return the private key and its did:key string +func generateKey(t *testing.T) (atcrypto.PrivateKey, string) { + t.Helper() + priv, err := atcrypto.GeneratePrivateKeyP256() + require.NoError(t, err) + pub, err := priv.PublicKey() + require.NoError(t, err) + return priv, pub.DIDKey() +} + +// helper: create a signed genesis RegularOp and return it along with the computed DID +func createGenesis(t *testing.T, priv atcrypto.PrivateKey, rotationKeys []string) (*didplc.RegularOp, string) { + t.Helper() + pub, err := priv.PublicKey() + require.NoError(t, err) + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: rotationKeys, + VerificationMethods: map[string]string{ + "atproto": pub.DIDKey(), + }, + AlsoKnownAs: []string{"at://test.example.com"}, + Services: map[string]didplc.OpService{ + "atproto_pds": { + Type: "AtprotoPersonalDataServer", + Endpoint: "https://pds.example.com", + }, + }, + Prev: nil, + } + require.NoError(t, op.Sign(priv)) + did, err := op.DID() + require.NoError(t, err) + return op, did +} + +// helper: create a signed update RegularOp that chains after prevCID +func createUpdate(t *testing.T, priv atcrypto.PrivateKey, rotationKeys []string, prevCID string) *didplc.RegularOp { + t.Helper() + pub, err := priv.PublicKey() + require.NoError(t, err) + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: rotationKeys, + VerificationMethods: map[string]string{ + "atproto": pub.DIDKey(), + }, + AlsoKnownAs: []string{"at://updated.example.com"}, + Services: map[string]didplc.OpService{ + "atproto_pds": { + Type: "AtprotoPersonalDataServer", + Endpoint: "https://pds2.example.com", + }, + }, + Prev: &prevCID, + } + require.NoError(t, op.Sign(priv)) + return op +} + +// helper: commit a genesis op to the store and return its CID +func commitGenesis(t *testing.T, ctx context.Context, store didplc.OpStore, op *didplc.RegularOp, did string, createdAt time.Time) string { + t.Helper() + prepOp, _, err := didplc.VerifyOperation(ctx, store, did, op, createdAt) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + return prepOp.OpCid +} + +func TestValidateInner_GenesisValid(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + + seqop := &SequencedOp{ + DID: did, + CID: op.CID().String(), + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Equal(did, prepOp.DID) + assert.Equal(op.CID().String(), prepOp.OpCid) + assert.Empty(prepOp.PrevHead) + assert.Nil(prepOp.NullifiedOps) +} + +func TestValidateInner_GenesisDIDMismatch(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + op, _ := createGenesis(t, priv, []string{pubKey}) + + seqop := &SequencedOp{ + DID: "did:plc:wrong", + CID: op.CID().String(), + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "genesis DID does not match") +} + +func TestValidateInner_GenesisCIDMismatch(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + + seqop := &SequencedOp{ + DID: did, + CID: "bafyreiwrongcidvalue", + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "inconsistent CID") +} + +func TestValidateInner_GenesisDuplicateDID(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + now := time.Now() + + // commit the genesis first + commitGenesis(t, ctx, store, op, did, now) + + // try to validate the same genesis again + seqop := &SequencedOp{ + DID: did, + CID: op.CID().String(), + Operation: op, + CreatedAt: now, + Seq: 2, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "already exists") +} + +func TestValidateInner_UpdateValid(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + t1 := t0.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t1, + Seq: 2, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Equal(did, prepOp.DID) + assert.Equal(genesisCID, prepOp.PrevHead) + assert.Nil(prepOp.NullifiedOps) +} + +func TestValidateInner_UpdateTimestampNotAdvanced(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + + // same timestamp as genesis — should fail + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t0, + Seq: 2, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "timestamp order") +} + +func TestValidateInner_UpdateWrongSignatureKey(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv1, pubKey1 := generateKey(t) + genesis, did := createGenesis(t, priv1, []string{pubKey1}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // sign update with a different key not in rotation keys + priv2, _ := generateKey(t) + update := createUpdate(t, priv2, []string{pubKey1}, genesisCID) + t1 := t0.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t1, + Seq: 2, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "signature invalid") +} + +func TestValidateInner_UpdateNonexistentDID(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + prevCID := "bafyreifakecid" + update := createUpdate(t, priv, []string{pubKey}, prevCID) + + seqop := &SequencedOp{ + DID: "did:plc:nonexistent", + CID: update.CID().String(), + Operation: update, + CreatedAt: time.Now(), + Seq: 1, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "DID not found") +} + +func TestValidateInner_Nullification(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + // nullification requires two rotation keys: a recovery key (index 0) + // and a regular key (index 1). The regular update uses key 1, which + // trims allowed keys to [:1]. The nullification then uses key 0. + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // create and commit a regular update signed by key at index 1 + update1 := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, _, err := didplc.VerifyOperation(ctx, store, did, update1, t1) + require.NoError(t, err) + require.Equal(t, 1, prepOp1.KeyIndex) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // now create a nullification signed by the recovery key (index 0), + // whose prev is the genesis (not update1) — this should nullify update1 + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + t2 := t1.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: nullify.CID().String(), + Operation: nullify, + CreatedAt: t2, + Seq: 3, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Len(prepOp.NullifiedOps, 1) + assert.Equal(update1.CID().String(), prepOp.NullifiedOps[0]) +} + +func TestValidateInner_NullificationTooSlow(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // create and commit a regular update signed by key at index 1 + update1 := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, _, err := didplc.VerifyOperation(ctx, store, did, update1, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // try nullification after 72h have passed since update1 + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + tLate := t1.Add(73 * time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: nullify.CID().String(), + Operation: nullify, + CreatedAt: tLate, + Seq: 3, + } + + _, err = validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "72h") +} + +func TestValidateInner_Tombstone(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + tombstone := &didplc.TombstoneOp{ + Type: "plc_tombstone", + Prev: genesisCID, + } + require.NoError(t, tombstone.Sign(priv)) + t1 := t0.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: tombstone.CID().String(), + Operation: tombstone, + CreatedAt: t1, + Seq: 2, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Equal(did, prepOp.DID) +} + +func TestValidateWorker_ValidOp(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + + seqops := make(chan *SequencedOp, 1) + validatedOps := make(chan ValidatedOp, 1) + + seqop := &SequencedOp{ + DID: did, + CID: op.CID().String(), + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + infl.AddInFlight(did, 1) + seqops <- seqop + close(seqops) + + ValidateWorker(ctx, seqops, validatedOps, infl, store) + close(validatedOps) + + var results []ValidatedOp + for vop := range validatedOps { + results = append(results, vop) + } + + assert.Len(results, 1) + assert.Equal(int64(1), results[0].Seq) + assert.Equal(did, results[0].PrepOp.DID) +} + +func TestValidateWorker_InvalidOp(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + op, _ := createGenesis(t, priv, []string{pubKey}) + + seqops := make(chan *SequencedOp, 1) + validatedOps := make(chan ValidatedOp, 1) + + // wrong DID should cause validation failure + seqop := &SequencedOp{ + DID: "did:plc:wrong", + CID: op.CID().String(), + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + infl.AddInFlight("did:plc:wrong", 1) + seqops <- seqop + close(seqops) + + ValidateWorker(ctx, seqops, validatedOps, infl, store) + close(validatedOps) + + var results []ValidatedOp + for vop := range validatedOps { + results = append(results, vop) + } + assert.Empty(results) + + // inflight should have been cleaned up + assert.True(infl.AddInFlight("did:plc:wrong", 2), "DID should be available again after failed validation") +} + +func TestCommitWorker_CommitsBatch(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + now := time.Now() + + prepOp, _, err := didplc.VerifyOperation(ctx, store, did, op, now) + require.NoError(t, err) + + validatedOps := make(chan ValidatedOp) // unbuffered: send blocks until worker reads + flushCh := make(chan chan struct{}) + + infl.AddInFlight(did, 1) + + workerDone := make(chan struct{}) + go func() { + CommitWorker(ctx, validatedOps, infl, store, flushCh) + close(workerDone) + }() + + // send blocks until CommitWorker reads, so we know it has the op + validatedOps <- ValidatedOp{Seq: 1, PrepOp: prepOp} + + // now flush — the op is guaranteed to be in the batch + done := make(chan struct{}) + flushCh <- done + <-done + + // verify it was committed + head, err := store.GetLatest(ctx, did) + assert.NoError(err) + assert.Equal(op.CID().String(), head.OpCid) + + // close to stop the worker and wait for it to exit + close(validatedOps) + <-workerDone + + // inflight should have been cleaned up + assert.True(infl.AddInFlight(did, 2)) +} + +func TestCommitWorker_FlushOnClose(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + now := time.Now() + + prepOp, _, err := didplc.VerifyOperation(ctx, store, did, op, now) + require.NoError(t, err) + + validatedOps := make(chan ValidatedOp, 1) + flushCh := make(chan chan struct{}) + + infl.AddInFlight(did, 1) + validatedOps <- ValidatedOp{Seq: 1, PrepOp: prepOp} + close(validatedOps) + + // run synchronously — CommitWorker returns when channel is closed + CommitWorker(ctx, validatedOps, infl, store, flushCh) + + head, err := store.GetLatest(ctx, did) + assert.NoError(err) + assert.Equal(op.CID().String(), head.OpCid) +} + +func TestEndToEnd_MultipleOps(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + // validate and commit genesis + genesisPrepOp, _, err := didplc.VerifyOperation(ctx, store, did, genesis, t0) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{genesisPrepOp})) + genesisCID := genesisPrepOp.OpCid + + // create update + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + t1 := t0.Add(time.Hour) + + seqops := make(chan *SequencedOp, 1) + validatedOps := make(chan ValidatedOp, 1) + flushCh := make(chan chan struct{}, 1) + + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t1, + Seq: 2, + } + infl.AddInFlight(did, 2) + seqops <- seqop + close(seqops) + + // run validate worker + ValidateWorker(ctx, seqops, validatedOps, infl, store) + close(validatedOps) + + // collect validated ops and commit + var validated []ValidatedOp + for vop := range validatedOps { + validated = append(validated, vop) + } + require.Len(t, validated, 1) + + // commit via store directly + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{validated[0].PrepOp})) + infl.RemoveInFlight(did, 2) + _ = flushCh // unused in this test path + + // verify head updated + head, err := store.GetLatest(ctx, did) + assert.NoError(err) + assert.Equal(update.CID().String(), head.OpCid) +} + +func TestValidateInner_RotationKeyChange(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv1, pubKey1 := generateKey(t) + priv2, pubKey2 := generateKey(t) + + // genesis with both keys as rotation keys + genesis, did := createGenesis(t, priv1, []string{pubKey1, pubKey2}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // update signed by key2 (second rotation key) — should succeed + update := createUpdate(t, priv2, []string{pubKey2}, genesisCID) + t1 := t0.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t1, + Seq: 2, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Equal(1, prepOp.KeyIndex, "should be signed by second rotation key") +}