From dc0891b67842e7c95ec05de2cd0a93a9a6bc405a Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 2 Sep 2026 00:15:55 +0900 Subject: [PATCH] deliberi,lexicons: resolve verified committers Signed-off-by: dawn --- api/tangled/identityresolveCommitters.go | 35 +++++++++ deliberi/config/config.go | 21 ++++- deliberi/config/config_test.go | 44 +++++++++++ deliberi/db/email.go | 22 ++++++ deliberi/xrpc/internal_committers.go | 44 +++++++++++ deliberi/xrpc/internal_committers_test.go | 77 +++++++++++++++++++ deliberi/xrpc/xrpc.go | 4 +- lexicons/identity/resolveCommitters.json | 53 +++++++++++++ nix/modules/deliberi.nix | 7 ++ web/lex.config.ts | 1 + web/src/lib/api/lexicons/index.ts | 1 + .../sh/tangled/identity/resolveCommitters.ts | 44 +++++++++++ 12 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 api/tangled/identityresolveCommitters.go create mode 100644 deliberi/config/config_test.go create mode 100644 deliberi/xrpc/internal_committers.go create mode 100644 deliberi/xrpc/internal_committers_test.go create mode 100644 lexicons/identity/resolveCommitters.json create mode 100644 web/src/lib/api/lexicons/types/sh/tangled/identity/resolveCommitters.ts diff --git a/api/tangled/identityresolveCommitters.go b/api/tangled/identityresolveCommitters.go new file mode 100644 index 000000000..3cfef84e4 --- /dev/null +++ b/api/tangled/identityresolveCommitters.go @@ -0,0 +1,35 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.identity.resolveCommitters + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + IdentityResolveCommittersNSID = "sh.tangled.identity.resolveCommitters" +) + +// IdentityResolveCommitters_Input is the input argument to a sh.tangled.identity.resolveCommitters call. +type IdentityResolveCommitters_Input struct { + Actor string `json:"actor" cborgen:"actor"` +} + +// IdentityResolveCommitters_Output is the output of a sh.tangled.identity.resolveCommitters call. +type IdentityResolveCommitters_Output struct { + Committers []string `json:"committers" cborgen:"committers"` +} + +// IdentityResolveCommitters calls the XRPC method "sh.tangled.identity.resolveCommitters". +func IdentityResolveCommitters(ctx context.Context, c util.LexClient, input *IdentityResolveCommitters_Input) (*IdentityResolveCommitters_Output, error) { + var out IdentityResolveCommitters_Output + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.identity.resolveCommitters", nil, input, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/deliberi/config/config.go b/deliberi/config/config.go index 63e8601f1..f09fa37ac 100644 --- a/deliberi/config/config.go +++ b/deliberi/config/config.go @@ -2,7 +2,9 @@ package config import ( "context" + "fmt" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/sethvargo/go-envconfig" ) @@ -30,7 +32,8 @@ type Config struct { KvApiToken string `env:"DELIBERI_KV_API_TOKEN"` KvNamespaceID string `env:"DELIBERI_KV_NAMESPACE_ID"` - TurnstileSecretKey string `env:"DELIBERI_TURNSTILE_SECRET_KEY"` + TurnstileSecretKey string `env:"DELIBERI_TURNSTILE_SECRET_KEY"` + TrustedServiceDIDs []string `env:"DELIBERI_TRUSTED_SERVICE_DIDS"` Dev bool `env:"DELIBERI_DEV, default=false"` } @@ -51,10 +54,26 @@ func (c *Config) SignupEnabled() bool { return c.Pds.AdminSecret != "" } +func (c *Config) TrustsService(did string) bool { + for _, trusted := range c.TrustedServiceDIDs { + if did == trusted { + return true + } + } + return false +} + func Load(ctx context.Context) (*Config, error) { var cfg Config if err := envconfig.Process(ctx, &cfg); err != nil { return nil, err } + for i, value := range cfg.TrustedServiceDIDs { + did, err := syntax.ParseDID(value) + if err != nil { + return nil, fmt.Errorf("DELIBERI_TRUSTED_SERVICE_DIDS: %w", err) + } + cfg.TrustedServiceDIDs[i] = did.String() + } return &cfg, nil } diff --git a/deliberi/config/config_test.go b/deliberi/config/config_test.go new file mode 100644 index 000000000..ef8a80f3c --- /dev/null +++ b/deliberi/config/config_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "context" + "testing" +) + +func TestLoadValidatesTrustedServiceDIDs(t *testing.T) { + for _, test := range []struct { + name string + value string + wantError bool + trusted []string + }{ + {name: "none"}, + {name: "one", value: "did:web:mirror.example", trusted: []string{"did:web:mirror.example"}}, + { + name: "many", + value: "did:web:mirror.example,did:web:indexer.example", + trusted: []string{"did:web:mirror.example", "did:web:indexer.example"}, + }, + {name: "one invalid", value: "did:web:mirror.example,not-a-did", wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("DELIBERI_HOSTNAME", "deliberi.example") + t.Setenv("DELIBERI_TRUSTED_SERVICE_DIDS", test.value) + cfg, err := Load(context.Background()) + if (err != nil) != test.wantError { + t.Fatalf("Load() error = %v, wantError = %v", err, test.wantError) + } + if err != nil { + return + } + if got, want := len(cfg.TrustedServiceDIDs), len(test.trusted); got != want { + t.Fatalf("trusted service DIDs = %v, want %v", cfg.TrustedServiceDIDs, test.trusted) + } + for _, did := range test.trusted { + if !cfg.TrustsService(did) { + t.Errorf("TrustsService(%q) = false", did) + } + } + }) + } +} diff --git a/deliberi/db/email.go b/deliberi/db/email.go index eaf3ae882..a32fece72 100644 --- a/deliberi/db/email.go +++ b/deliberi/db/email.go @@ -125,6 +125,28 @@ func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string return assoc, rows.Err() } +func GetVerifiedEmailAddresses(e Execer, did string) ([]string, error) { + rows, err := e.Query(` + select lower(email) + from emails + where did = ? and verified = 1 + order by lower(email) + limit 250`, did) + if err != nil { + return nil, err + } + defer rows.Close() + var addresses []string + for rows.Next() { + var address string + if err := rows.Scan(&address); err != nil { + return nil, err + } + addresses = append(addresses, address) + } + return addresses, rows.Err() +} + func GetVerificationCodeForEmail(e Execer, did string, email string) (string, error) { query := ` select verification_code diff --git a/deliberi/xrpc/internal_committers.go b/deliberi/xrpc/internal_committers.go new file mode 100644 index 000000000..9dd1c5f38 --- /dev/null +++ b/deliberi/xrpc/internal_committers.go @@ -0,0 +1,44 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + db "tangled.org/core/deliberi/db" +) + +func (x *Xrpc) ResolveCommitters(w http.ResponseWriter, r *http.Request) { + caller, ok := actorDid(r) + if !ok || x.Config == nil || len(x.Config.TrustedServiceDIDs) == 0 { + http.NotFound(w, r) + return + } + if !x.Config.TrustsService(caller) { + writeError(w, xrpcErrorTag("Forbidden", "caller is not authorized"), http.StatusForbidden) + return + } + + var input tangled.IdentityResolveCommitters_Input + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4*1024)) + if err := decoder.Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + actor, err := syntax.ParseDID(input.Actor) + if err != nil { + writeError(w, badRequestError("actor must be a DID"), http.StatusBadRequest) + return + } + addresses, err := db.GetVerifiedEmailAddresses(x.DB, actor.String()) + if err != nil { + x.Logger.Error("listing verified committer emails failed", "actor", actor, "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + committers := make([]string, 1, len(addresses)+1) + committers[0] = actor.String() + committers = append(committers, addresses...) + x.writeJSON(w, tangled.IdentityResolveCommitters_Output{Committers: committers}) +} diff --git a/deliberi/xrpc/internal_committers_test.go b/deliberi/xrpc/internal_committers_test.go new file mode 100644 index 000000000..8f2f990bf --- /dev/null +++ b/deliberi/xrpc/internal_committers_test.go @@ -0,0 +1,77 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "tangled.org/core/api/tangled" + db "tangled.org/core/deliberi/db" + "tangled.org/core/deliberi/models" +) + +func TestResolveCommitters(t *testing.T) { + _, router, database, sign := newTestXrpcFull(t, func(x *Xrpc) { + x.Config.TrustedServiceDIDs = []string{"did:web:another.example", testActor} + }) + for _, email := range []models.Email{ + {Did: testActor, Address: "Verified@Example.com", Verified: true, VerificationCode: ""}, + {Did: testActor, Address: "pending@example.com", Verified: false, VerificationCode: "pending"}, + } { + if err := db.AddEmail(database, email); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest( + http.MethodPost, + "/"+tangled.IdentityResolveCommittersNSID, + strings.NewReader(`{"actor":"`+testActor+`"}`), + ) + req.Header.Set("Authorization", "Bearer "+sign(tangled.IdentityResolveCommittersNSID)) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var output tangled.IdentityResolveCommitters_Output + if err := json.Unmarshal(response.Body.Bytes(), &output); err != nil { + t.Fatal(err) + } + if got, want := strings.Join(output.Committers, ","), testActor+",verified@example.com"; got != want { + t.Fatalf("committers = %q, want %q", got, want) + } +} + +func TestResolveCommittersRejectsAnotherService(t *testing.T) { + _, router, _, sign := newTestXrpcFull(t, func(x *Xrpc) { + x.Config.TrustedServiceDIDs = []string{"did:web:mirror.example", "did:web:indexer.example"} + }) + req := httptest.NewRequest( + http.MethodPost, + "/"+tangled.IdentityResolveCommittersNSID, + strings.NewReader(`{"actor":"`+testActor+`"}`), + ) + req.Header.Set("Authorization", "Bearer "+sign(tangled.IdentityResolveCommittersNSID)) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", response.Code) + } +} + +func TestResolveCommittersIsDisabledWithoutTrustedServices(t *testing.T) { + router, _, sign := newTestXrpc(t) + req := httptest.NewRequest( + http.MethodPost, + "/"+tangled.IdentityResolveCommittersNSID, + strings.NewReader(`{"actor":"`+testActor+`"}`), + ) + req.Header.Set("Authorization", "Bearer "+sign(tangled.IdentityResolveCommittersNSID)) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", response.Code) + } +} diff --git a/deliberi/xrpc/xrpc.go b/deliberi/xrpc/xrpc.go index d08df9bc3..3ebabf4b1 100644 --- a/deliberi/xrpc/xrpc.go +++ b/deliberi/xrpc/xrpc.go @@ -10,6 +10,7 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" tangled "tangled.org/core/api/org_tangled" + shTangled "tangled.org/core/api/tangled" config "tangled.org/core/deliberi/config" db "tangled.org/core/deliberi/db" "tangled.org/core/deliberi/kv" @@ -45,10 +46,11 @@ func (x *Xrpc) Router() http.Handler { r.Post("/"+tangled.TempAccountResendSignupNSID, x.AccountResendSignup) r.Post("/"+tangled.TempAccountCompleteSignupNSID, x.AccountCompleteSignup) r.Post("/"+tangled.TempAccountVerifyEmailNSID, x.AccountVerifyEmail) - r.Group(func(r chi.Router) { r.Use(x.ServiceAuth.VerifyServiceAuth) + r.Post("/"+shTangled.IdentityResolveCommittersNSID, x.ResolveCommitters) + r.Get("/"+tangled.TempNotificationListNotificationsNSID, x.NotificationList) r.Get("/"+tangled.TempNotificationGetUnreadCountNSID, x.NotificationGetUnreadCount) r.Post("/"+tangled.TempNotificationUpdateSeenNSID, x.NotificationUpdateSeen) diff --git a/lexicons/identity/resolveCommitters.json b/lexicons/identity/resolveCommitters.json new file mode 100644 index 000000000..aa7de70eb --- /dev/null +++ b/lexicons/identity/resolveCommitters.json @@ -0,0 +1,53 @@ +{ + "lexicon": 1, + "id": "sh.tangled.identity.resolveCommitters", + "defs": { + "main": { + "type": "procedure", + "description": "Resolve an actor DID to its verified Git committer identities.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "actor" + ], + "properties": { + "actor": { + "type": "string", + "format": "did" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "committers" + ], + "properties": { + "committers": { + "type": "array", + "maxLength": 251, + "items": { + "type": "string" + } + } + } + } + }, + "errors": [ + { + "name": "InvalidRequest", + "description": "The actor is not a valid DID" + }, + { + "name": "Forbidden", + "description": "The calling service is not authorized" + } + ] + } + } +} diff --git a/nix/modules/deliberi.nix b/nix/modules/deliberi.nix index 971685262..f22c2af77 100644 --- a/nix/modules/deliberi.nix +++ b/nix/modules/deliberi.nix @@ -59,6 +59,12 @@ in default = false; }; + trustedServiceDids = mkOption { + type = types.listOf types.str; + default = []; + description = "service DIDs authorized to call trusted internal XRPC endpoints"; + }; + environmentFile = mkOption { type = types.nullOr types.path; default = null; @@ -83,6 +89,7 @@ in "DELIBERI_JETSTREAM_ENDPOINT=${cfg.jetstreamEndpoint}" "DELIBERI_BOBBIN_API_URL=${cfg.bobbinApiUrl}" "DELIBERI_BASE_URL=${cfg.baseUrl}" + "DELIBERI_TRUSTED_SERVICE_DIDS=${concatStringsSep "," cfg.trustedServiceDids}" "DELIBERI_DEV=${boolToString cfg.dev}" ]; ExecStart = "${getExe cfg.package} serve"; diff --git a/web/lex.config.ts b/web/lex.config.ts index 4f2e722c5..dba40075f 100644 --- a/web/lex.config.ts +++ b/web/lex.config.ts @@ -16,6 +16,7 @@ export default defineLexiconConfig({ "../lexicons/feed/**/*.json", "../lexicons/git/**/*.json", "../lexicons/graph/**/*.json", + "../lexicons/identity/**/*.json", "../lexicons/issue/**/*.json", "../lexicons/knot/**/*.json", "../lexicons/label/**/*.json", diff --git a/web/src/lib/api/lexicons/index.ts b/web/src/lib/api/lexicons/index.ts index 868a95e49..bcbd6e21d 100644 --- a/web/src/lib/api/lexicons/index.ts +++ b/web/src/lib/api/lexicons/index.ts @@ -121,6 +121,7 @@ export * as ShTangledGraphListNetworkVouches from "./types/sh/tangled/graph/list export * as ShTangledGraphListVouches from "./types/sh/tangled/graph/listVouches.js"; export * as ShTangledGraphListVouchesBy from "./types/sh/tangled/graph/listVouchesBy.js"; export * as ShTangledGraphVouch from "./types/sh/tangled/graph/vouch.js"; +export * as ShTangledIdentityResolveCommitters from "./types/sh/tangled/identity/resolveCommitters.js"; export * as ShTangledKnot from "./types/sh/tangled/knot.js"; export * as ShTangledKnotAcceptMembership from "./types/sh/tangled/knot/acceptMembership.js"; export * as ShTangledKnotAddMember from "./types/sh/tangled/knot/addMember.js"; diff --git a/web/src/lib/api/lexicons/types/sh/tangled/identity/resolveCommitters.ts b/web/src/lib/api/lexicons/types/sh/tangled/identity/resolveCommitters.ts new file mode 100644 index 000000000..a15039b2e --- /dev/null +++ b/web/src/lib/api/lexicons/types/sh/tangled/identity/resolveCommitters.ts @@ -0,0 +1,44 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.procedure( + "sh.tangled.identity.resolveCommitters", + { + params: null, + input: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + actor: /*#__PURE__*/ v.didString(), + }), + }, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * @maxLength 251 + */ + committers: /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), + [/*#__PURE__*/ v.arrayLength(0, 251)], + ), + }), + }, + }, +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $input extends v.InferXRPCBodyInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCProcedures { + "sh.tangled.identity.resolveCommitters": mainSchema; + } +} -- 2.51.2