diff --git a/docker-compose.yml b/docker-compose.yml index e65019629..ce8166019 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -446,6 +446,7 @@ services: DELIBERI_TRUSTED_SERVICE_DIDS: did:web:mirror.tngl.boltless.dev volumes: - deliberi-data:/var/lib/deliberi + - init-state:/shared:ro - ./localinfra/certs/root.crt:/usr/local/share/ca-certificates/caddy.crt:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:6565/xrpc/_health"] @@ -458,6 +459,8 @@ services: depends_on: pds: condition: service_healthy + init-accounts: + condition: service_completed_successfully networks: [tngl] pdsls: diff --git a/localinfra/cmd/deliberi-bootstrap/main.go b/localinfra/cmd/deliberi-bootstrap/main.go new file mode 100644 index 000000000..3a35a4d5e --- /dev/null +++ b/localinfra/cmd/deliberi-bootstrap/main.go @@ -0,0 +1,128 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/mail" + "os" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + deldb "tangled.org/core/deliberi/db" +) + +const maxVerifiedEmailFixtures = 1000 + +type verifiedEmailFixture struct { + DID string `json:"did"` + Email string `json:"email"` +} + +func main() { + dbPath := flag.String("db-path", os.Getenv("DELIBERI_DB_PATH"), "path to the Deliberi SQLite database") + fixturePath := flag.String("file", "", "path to the verified email JSONL fixture") + flag.Parse() + if *dbPath == "" || *fixturePath == "" { + fmt.Fprintln(os.Stderr, "both --db-path and --file are required") + os.Exit(2) + } + if err := bootstrapVerifiedEmails(context.Background(), *dbPath, *fixturePath); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func parseVerifiedEmailFixtures(r io.Reader) ([]verifiedEmailFixture, error) { + scanner := bufio.NewScanner(r) + fixtures := make([]verifiedEmailFixture, 0) + seenEmails := make(map[string]string) + for lineNumber := 1; scanner.Scan(); lineNumber++ { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if len(fixtures) >= maxVerifiedEmailFixtures { + return nil, fmt.Errorf("verified email fixture contains more than %d entries", maxVerifiedEmailFixtures) + } + + var fixture verifiedEmailFixture + if err := json.Unmarshal([]byte(line), &fixture); err != nil { + return nil, fmt.Errorf("verified email fixture line %d: %w", lineNumber, err) + } + did, err := syntax.ParseDID(fixture.DID) + if err != nil { + return nil, fmt.Errorf("verified email fixture line %d has invalid DID: %w", lineNumber, err) + } + fixture.DID = did.String() + fixture.Email = strings.ToLower(strings.TrimSpace(fixture.Email)) + address, err := mail.ParseAddress(fixture.Email) + if err != nil || address.Name != "" || address.Address != fixture.Email { + return nil, fmt.Errorf("verified email fixture line %d has invalid email address", lineNumber) + } + if owner, ok := seenEmails[fixture.Email]; ok { + if owner != fixture.DID { + return nil, fmt.Errorf("verified email fixture assigns %s to both %s and %s", fixture.Email, owner, fixture.DID) + } + continue + } + seenEmails[fixture.Email] = fixture.DID + fixtures = append(fixtures, fixture) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading verified email fixture: %w", err) + } + if len(fixtures) == 0 { + return nil, fmt.Errorf("verified email fixture contains no entries") + } + return fixtures, nil +} + +func bootstrapVerifiedEmails(ctx context.Context, dbPath, fixturePath string) error { + file, err := os.Open(fixturePath) + if err != nil { + return fmt.Errorf("opening verified email fixture: %w", err) + } + defer file.Close() + fixtures, err := parseVerifiedEmailFixtures(file) + if err != nil { + return err + } + + database, err := deldb.Make(ctx, dbPath) + if err != nil { + return fmt.Errorf("initializing db: %w", err) + } + defer database.Close() + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("starting verified email fixture transaction: %w", err) + } + defer tx.Rollback() + + for _, fixture := range fixtures { + if _, err := tx.Exec(` + insert into emails (did, email, verified, is_primary, verification_code) + values (?, ?, true, false, '') + on conflict(did, email) do update set verified = true, verification_code = ''`, + fixture.DID, fixture.Email); err != nil { + return fmt.Errorf("upserting %s for %s: %w", fixture.Email, fixture.DID, err) + } + var hasPrimary bool + if err := tx.QueryRow(`select exists(select 1 from emails where did = ? and verified = true and is_primary = true)`, fixture.DID).Scan(&hasPrimary); err != nil { + return fmt.Errorf("checking primary email for %s: %w", fixture.DID, err) + } + if !hasPrimary { + if err := deldb.MakeEmailPrimary(tx, fixture.DID, fixture.Email); err != nil { + return fmt.Errorf("making %s primary for %s: %w", fixture.Email, fixture.DID, err) + } + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing verified email fixtures: %w", err) + } + return nil +} diff --git a/localinfra/cmd/deliberi-bootstrap/main_test.go b/localinfra/cmd/deliberi-bootstrap/main_test.go new file mode 100644 index 000000000..6455e282f --- /dev/null +++ b/localinfra/cmd/deliberi-bootstrap/main_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + + deldb "tangled.org/core/deliberi/db" +) + +func TestParseVerifiedEmailFixtures(t *testing.T) { + fixtures, err := parseVerifiedEmailFixtures(strings.NewReader(` +{"did":"did:plc:alicefixture","email":" ALICE@Example.Test "} +{"did":"did:plc:bobfixture","email":"bob@example.test"} +`)) + if err != nil { + t.Fatal(err) + } + if len(fixtures) != 2 { + t.Fatalf("len(fixtures) = %d, want 2", len(fixtures)) + } + if got, want := fixtures[0].Email, "alice@example.test"; got != want { + t.Fatalf("email = %q, want %q", got, want) + } +} + +func TestParseVerifiedEmailFixturesRejectsConflictingOwners(t *testing.T) { + _, err := parseVerifiedEmailFixtures(strings.NewReader(` +{"did":"did:plc:alicefixture","email":"shared@example.test"} +{"did":"did:plc:bobfixture","email":"shared@example.test"} +`)) + if err == nil || !strings.Contains(err.Error(), "assigns shared@example.test to both") { + t.Fatalf("error = %v, want conflicting owner error", err) + } +} + +func TestBootstrapVerifiedEmailsIsIdempotent(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/deliberi.db" + fixturePath := t.TempDir() + "/verified-emails.jsonl" + fixture := `{"did":"did:plc:alicefixture","email":"alice@example.test"} +{"did":"did:plc:bobfixture","email":"bob@example.test"} +` + if err := writeFixture(fixturePath, fixture); err != nil { + t.Fatal(err) + } + + for range 2 { + if err := bootstrapVerifiedEmails(ctx, dbPath, fixturePath); err != nil { + t.Fatal(err) + } + } + + database, err := deldb.Make(ctx, dbPath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + for _, did := range []string{"did:plc:alicefixture", "did:plc:bobfixture"} { + addresses, err := deldb.GetVerifiedEmailAddresses(database, did) + if err != nil { + t.Fatal(err) + } + if len(addresses) != 1 { + t.Fatalf("verified addresses for %s = %v, want one", did, addresses) + } + } + var count int + if err := database.QueryRow(`select count(*) from emails`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("email count = %d, want 2", count) + } +} + +func TestBootstrapVerifiedEmailsMarksExistingAddressVerified(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/deliberi.db" + database, err := deldb.Make(ctx, dbPath) + if err != nil { + t.Fatal(err) + } + if err := deldb.InsertUnverifiedEmail(database, "did:plc:alicefixture", "other@example.test", "other-token"); err != nil { + t.Fatal(err) + } + if err := deldb.InsertUnverifiedEmail(database, "did:plc:alicefixture", "alice@example.test", "fixture-token"); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + + fixturePath := t.TempDir() + "/verified-emails.jsonl" + if err := writeFixture(fixturePath, `{"did":"did:plc:alicefixture","email":"alice@example.test"}`); err != nil { + t.Fatal(err) + } + if err := bootstrapVerifiedEmails(ctx, dbPath, fixturePath); err != nil { + t.Fatal(err) + } + + database, err = deldb.Make(ctx, dbPath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + email, err := deldb.GetEmail(database, "did:plc:alicefixture", "alice@example.test") + if err != nil { + t.Fatal(err) + } + if !email.Verified { + t.Fatal("existing fixture email is not verified") + } + if !email.Primary { + t.Fatal("first verified fixture email is not primary") + } + if email.VerificationCode != "" { + t.Fatalf("verification code = %q, want empty", email.VerificationCode) + } +} + +func writeFixture(path, contents string) error { + return os.WriteFile(path, []byte(contents), 0o600) +} diff --git a/localinfra/deliberi.Dockerfile b/localinfra/deliberi.Dockerfile index 8f579e3b3..8af2553c5 100644 --- a/localinfra/deliberi.Dockerfile +++ b/localinfra/deliberi.Dockerfile @@ -18,17 +18,19 @@ RUN --mount=type=cache,target=/go/cache \ COPY . . RUN --mount=type=cache,target=/go/cache \ --mount=type=cache,target=/go/mod \ - go build -tags libsqlite3 -o /out/deliberi ./cmd/deliberi + go build -tags libsqlite3 -o /out/deliberi ./cmd/deliberi && \ + go build -tags libsqlite3 -o /out/deliberi-bootstrap ./localinfra/cmd/deliberi-bootstrap FROM alpine:3.24 RUN apk add --no-cache ca-certificates sqlite-libs tini COPY --from=builder /out/deliberi /usr/local/bin/deliberi -RUN chmod 0755 /usr/local/bin/deliberi +COPY --from=builder /out/deliberi-bootstrap /usr/local/bin/deliberi-bootstrap +RUN chmod 0755 /usr/local/bin/deliberi /usr/local/bin/deliberi-bootstrap VOLUME /var/lib/deliberi EXPOSE 6565 ENTRYPOINT ["/sbin/tini", "--"] -CMD ["sh", "-c", "if [ -f /usr/local/share/ca-certificates/caddy.crt ]; then update-ca-certificates; fi && exec /usr/local/bin/deliberi serve"] +CMD ["sh", "-c", "if [ -f /usr/local/share/ca-certificates/caddy.crt ]; then update-ca-certificates; fi && test -r /shared/verified-emails.jsonl && /usr/local/bin/deliberi-bootstrap --file /shared/verified-emails.jsonl && exec /usr/local/bin/deliberi serve"] diff --git a/localinfra/readme.md b/localinfra/readme.md index 03103e0f1..97086b6a8 100644 --- a/localinfra/readme.md +++ b/localinfra/readme.md @@ -63,7 +63,7 @@ To make that work: ``` This writes the image directory under `out/localinfra-spindle-images`. 5. `docker compose up` -6. AppView will be running on `127.0.0.1:3000` with two test users: `alice.pds.tngl.boltless.dev` and `bob.pds.tngl.boltless.dev`. Both with password `password`. +6. AppView will be running on `127.0.0.1:3000` with four test users: `alice`, `bob`, `charlie`, and `david`, all under `pds.tngl.boltless.dev` and using the password `password`. Deliberi also bootstraps a verified `${user}@pds.tngl.boltless.dev` address for each account. Use that address as `user.email` when making local Git commits that should appear in profile activity. `TANGLED_APPVIEW_HOST` must be a loopback IP with the mapped port (`127.0.0.1:3000`), not `localhost`: atproto's dev OAuth client requires a loopback IP for the redirect URI. If you remap the published appview port, update `TANGLED_APPVIEW_HOST` in `docker-compose.yml` to match. ## Observability diff --git a/localinfra/scripts/init-accounts.sh b/localinfra/scripts/init-accounts.sh index 1b4c778d0..c6713f8ab 100644 --- a/localinfra/scripts/init-accounts.sh +++ b/localinfra/scripts/init-accounts.sh @@ -1,6 +1,7 @@ #!/bin/sh # dev bootstrap: -# - create accounts (alice, bob) +# - create accounts (alice, bob, charlie, david) +# - write verified committer email fixtures for Deliberi # - write OWNER_DID to /shared/owner-did (for knot/spindle) # - create system label definitions under SYSTEM_DID set -eu @@ -11,6 +12,8 @@ USERS="alice bob charlie david" OWNER_USER="${OWNER_USER:-alice}" SYSTEM_USER="${SYSTEM_USER:-alice}" SHARED_DIR="${SHARED_DIR:-/shared}" +VERIFIED_EMAILS_FILE="${SHARED_DIR}/verified-emails.jsonl" +VERIFIED_EMAILS_TMP="${VERIFIED_EMAILS_FILE}.tmp" # --- helpers --- @@ -43,10 +46,16 @@ ensure_account() { } # ensure accounts +mkdir -p "$SHARED_DIR" +: > "$VERIFIED_EMAILS_TMP" OWNER_DID="" SYSTEM_DID="" for u in $USERS; do did=$(ensure_account "$u") + jq -cn \ + --arg did "$did" \ + --arg email "${u}@${PDS_HOSTNAME}" \ + '{did: $did, email: $email}' >> "$VERIFIED_EMAILS_TMP" if [ "$u" = "$OWNER_USER" ]; then OWNER_DID="$did" fi @@ -58,7 +67,8 @@ done [ -n "$OWNER_DID" ] || { printf 'OWNER_USER %s not in USERS list\n' "$OWNER_USER" >&2; exit 1; } [ -n "$SYSTEM_DID" ] || { printf 'SYSTEM_USER %s not in USERS list\n' "$SYSTEM_USER" >&2; exit 1; } -mkdir -p "$SHARED_DIR" +mv "$VERIFIED_EMAILS_TMP" "$VERIFIED_EMAILS_FILE" +printf '[emails] wrote verified committer fixtures for %s\n' "$USERS" >&2 printf '%s' "$OWNER_DID" > "${SHARED_DIR}/owner-did" printf '[owner] %s → %s/owner-did\n' "$OWNER_USER" "$SHARED_DIR" >&2 printf '%s' "$SYSTEM_DID" > "${SHARED_DIR}/system-did"