diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index 2d7c623..c81c85b 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -41,6 +41,8 @@ type agentRow struct { ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` CapabilitiesHash sql.NullString `db:"capabilities_hash"` DNSRecordStyle sql.NullString `db:"dns_record_style"` + AnchorType sql.NullString `db:"anchor_type"` + AnchorResolvedID sql.NullString `db:"anchor_resolved_id"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -88,9 +90,33 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.DNSRecordStyle.Valid { reg.DNSRecordStyle = domain.DNSRecordStyle(r.DNSRecordStyle.String) } + // AnchorClaim is reconstructed from anchor_type + anchor_resolved_id. + // PublicKeyJWK and IssuedAt are intentionally not persisted: verifiers + // re-resolve the claim through the AnchorResolver on demand to honor + // the per-profile freshness budget. Pre-Plan-G rows have anchor_type + // NULL and surface a nil AnchorClaim downstream. + if r.AnchorType.Valid && r.AnchorType.String != "" { + reg.AnchorClaim = &domain.IdentityClaim{ + AnchorType: domain.AnchorType(r.AnchorType.String), + } + if r.AnchorResolvedID.Valid { + reg.AnchorClaim.ResolvedID = r.AnchorResolvedID.String + } + } return reg, nil } +// anchorClaimColumns returns the (anchor_type, anchor_resolved_id) +// values for the INSERT INTO agent_registrations row. Both are NULL +// when the aggregate carries no AnchorClaim (legacy FQDN-only path). +func anchorClaimColumns(claim *domain.IdentityClaim) (sql.NullString, sql.NullString) { + if claim == nil || claim.AnchorType == "" { + return sql.NullString{}, sql.NullString{} + } + return sql.NullString{String: string(claim.AnchorType), Valid: true}, + sql.NullString{String: claim.ResolvedID, Valid: true} +} + // Save inserts or updates an AgentRegistration. Endpoints, server cert, // and identity CSR are persisted via their dedicated tables — Save only // writes the root aggregate row. @@ -110,15 +136,19 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) acme_dns01_token, acme_challenge_expires_at_ms, capabilities_hash, dns_record_style, + anchor_type, anchor_resolved_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` // AnsName is the zero value for §3.2.0 base-only registrations. // Persist it as NULL so the UNIQUE constraint allows multiple // base-only rows (SQLite treats each NULL as distinct under // UNIQUE), and read the FQDN from agent.FQDN() which falls back // to agent.AgentHost when AnsName is zero. AnsName.String() // returns "" for the zero value; nullableString promotes it to - // SQL NULL. + // SQL NULL. AnchorClaim is the verified IdentityClaim from the + // caller; pre-Plan-G code paths leave it nil and the columns + // stay NULL. + anchorType, anchorResolvedID := anchorClaimColumns(agent.AnchorClaim) res, err := s.db.extx(ctx).ExecContext(ctx, q, agent.AgentID, agent.OwnerID, @@ -135,6 +165,8 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), nullableString(string(agent.DNSRecordStyle)), + anchorType, + anchorResolvedID, now, now, ) if err != nil { diff --git a/internal/adapter/store/sqlite/agent_test.go b/internal/adapter/store/sqlite/agent_test.go index 736b2b8..a77e28a 100644 --- a/internal/adapter/store/sqlite/agent_test.go +++ b/internal/adapter/store/sqlite/agent_test.go @@ -498,6 +498,99 @@ func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) { } } +func TestAgentStore_AnchorClaim_RoundTrip(t *testing.T) { + db := newTestDB(t) + store := NewAgentStore(db) + ctx := context.Background() + + cases := []struct { + name string + claim *domain.IdentityClaim + }{ + { + name: "FQDN claim", + claim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeFQDN, + ResolvedID: "agent.example.com", + }, + }, + { + name: "DID claim", + claim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + }, + }, + { + name: "LEI claim", + claim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + }, + }, + { + name: "no claim (legacy path)", + claim: nil, + }, + } + + for i, c := range cases { + t.Run(c.name, func(t *testing.T) { + // Distinct host + agent ID per case so the per-FQDN + // base-only uniqueness check does not interfere. + agentID := "agent-anchor-" + strconvI(i) + host := "agent" + strconvI(i) + ".example.com" + fixture := newBaseOnlyFixture(t, agentID, host) + fixture.AnchorClaim = c.claim + + if err := store.Save(ctx, fixture); err != nil { + t.Fatalf("Save: %v", err) + } + loaded, err := store.FindByID(ctx, fixture.ID) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + if c.claim == nil { + if loaded.AnchorClaim != nil { + t.Errorf("expected nil AnchorClaim, got %+v", loaded.AnchorClaim) + } + return + } + if loaded.AnchorClaim == nil { + t.Fatal("expected AnchorClaim, got nil") + } + if loaded.AnchorClaim.AnchorType != c.claim.AnchorType { + t.Errorf("AnchorType: got %q, want %q", + loaded.AnchorClaim.AnchorType, c.claim.AnchorType) + } + if loaded.AnchorClaim.ResolvedID != c.claim.ResolvedID { + t.Errorf("ResolvedID: got %q, want %q", + loaded.AnchorClaim.ResolvedID, c.claim.ResolvedID) + } + // PublicKeyJWK is intentionally not persisted; verifiers + // re-resolve through the AnchorResolver to honor the + // per-profile freshness budget. + if len(loaded.AnchorClaim.PublicKeyJWK) != 0 { + t.Errorf("PublicKeyJWK should not be persisted, got %d bytes", + len(loaded.AnchorClaim.PublicKeyJWK)) + } + }) + } +} + +// strconvI keeps the test imports minimal. +func strconvI(n int) string { + if n == 0 { + return "0" + } + digits := []byte{} + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} + // TestAgentStore_ExistsByAnsName_ZeroIsFalse pins the contract // guarding base-only registrations: looking up the zero-value AnsName // must short-circuit to false rather than match every empty ans_name diff --git a/internal/adapter/store/sqlite/migrations/009_agent_anchor_claim.sql b/internal/adapter/store/sqlite/migrations/009_agent_anchor_claim.sql new file mode 100644 index 0000000..7949bb9 --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/009_agent_anchor_claim.sql @@ -0,0 +1,24 @@ +-- 009_agent_anchor_claim.sql +-- +-- Plan G Slice 5b: persist the AnchorClaim that produced a +-- registration. ANS-0 admits three anchor types (fqdn, did, lei); +-- the storage shape captures the type plus the canonical resolved +-- ID. The verification key (PublicKeyJWK) is intentionally NOT +-- stored on this row — verifiers re-resolve through the +-- AnchorResolver to honor the per-profile freshness budget, and +-- a stale stored JWK would defeat that. +-- +-- Both columns are nullable. Pre-Plan-G registrations have no +-- AnchorClaim recorded; their anchor_type stays NULL and the +-- aggregate exposes a nil AnchorClaim downstream. The application +-- layer infers an FQDN claim from agent_host for those legacy rows +-- when needed without writing into this column on read. +-- +-- Adding columns to an existing SQLite table is a simple ALTER; +-- no rebuild ceremony required. + +ALTER TABLE agent_registrations + ADD COLUMN anchor_type TEXT; + +ALTER TABLE agent_registrations + ADD COLUMN anchor_resolved_id TEXT; diff --git a/internal/domain/agent.go b/internal/domain/agent.go index f09814c..e8aaf6e 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -135,6 +135,20 @@ type AgentRegistration struct { // is treated as DefaultDNSRecordStyle by ComputeRequiredDNSRecords. DNSRecordStyle DNSRecordStyle `json:"dnsRecordStyle,omitempty"` + // AnchorClaim records the verified IdentityClaim that produced + // this registration, when the caller supplied one through ANS-0's + // AnchorResolver port. Nil for legacy FQDN-only deployments where + // the registration's identity is implicit in AgentHost (and AnsName + // when versioned). When non-nil, AnchorClaim.AnchorType authoritatively + // identifies the anchor profile — non-FQDN profiles (DID, LEI) force + // base-only registration invariants because the X.509 URI SAN binding + // the versioned ANS-2 path relies on does not apply. + // + // Slice 5a stores AnchorClaim in-memory only; persistence lands in + // Slice 5b through migration 009 (anchor_type + anchor_resolved_id + // columns on agent_registrations). + AnchorClaim *IdentityClaim `json:"anchorClaim,omitempty"` + // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. PendingEvents []Event `json:"-"` @@ -157,6 +171,13 @@ type AgentRegistration struct { // Mixed forms are rejected: a version requires an Identity CSR (and // vice versa) — the Identity Certificate's URI SAN encodes the // ANSName, so the two artifacts are coupled. +// +// Non-FQDN anchors (DID, LEI) force the base-only path: today the +// versioned URI SAN binding is FQDN-shaped and a future amendment +// will admit DID-shaped URIs into the SAN. Until then, an +// AnchorClaim with anchorType in {did, lei} accompanied by a non-zero +// ansName or non-nil identityCSR is rejected with +// NON_FQDN_REQUIRES_BASE_ONLY. func NewRegistration( agentID string, ownerID string, @@ -167,6 +188,7 @@ func NewRegistration( endpoints []AgentEndpoint, serverCert *ByocServerCertificate, identityCSR *AgentCSR, + anchorClaim *IdentityClaim, now time.Time, ) (*AgentRegistration, error) { if agentID == "" { @@ -190,6 +212,20 @@ func NewRegistration( ) } + // Non-FQDN anchors force base-only until ANS-2 admits a DID-shaped URI + // SAN. A versioned (or CSR-bearing) DID/LEI registration is rejected + // at the boundary so the aggregate's invariants stay coherent. + if anchorClaim != nil && + anchorClaim.AnchorType != "" && + anchorClaim.AnchorType != AnchorTypeFQDN && + !baseOnly { + return nil, NewValidationError( + "NON_FQDN_REQUIRES_BASE_ONLY", + fmt.Sprintf("anchor type %q must be registered as base-only (no version, no identity CSR) until ANS-2 admits non-FQDN URI SANs", + anchorClaim.AnchorType), + ) + } + // Resolve canonical FQDN. Versioned: ansName carries it. Base-only: // caller passes agentHost explicitly. fqdn := strings.ToLower(strings.TrimSpace(agentHost)) @@ -242,11 +278,12 @@ func NewRegistration( } reg := &AgentRegistration{ - AgentID: agentID, - OwnerID: ownerID, - AnsName: ansName, - AgentHost: fqdn, - Status: StatusPendingValidation, + AgentID: agentID, + OwnerID: ownerID, + AnsName: ansName, + AgentHost: fqdn, + AnchorClaim: anchorClaim, + Status: StatusPendingValidation, Details: RegistrationDetails{ RegistrationTimestamp: now, DisplayName: displayName, diff --git a/internal/domain/agent_test.go b/internal/domain/agent_test.go index b8d9ab3..479b179 100644 --- a/internal/domain/agent_test.go +++ b/internal/domain/agent_test.go @@ -29,7 +29,7 @@ func newValidRegistration(t *testing.T) *AgentRegistration { reg, err := NewRegistration( "agent-uuid", "owner-1", ansName, "", "My Agent", "desc", - endpoints, cert, &csr, time.Now(), + endpoints, cert, &csr, nil, time.Now(), ) require.NoError(t, err) return reg @@ -75,7 +75,7 @@ func TestNewRegistration_Validations(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, err := NewRegistration(tc.agentID, tc.ownerID, tc.ansName, "", tc.displayName, tc.description, tc.endpoints, tc.cert, tc.csr, time.Now()) + _, err := NewRegistration(tc.agentID, tc.ownerID, tc.ansName, "", tc.displayName, tc.description, tc.endpoints, tc.cert, tc.csr, nil, time.Now()) require.Error(t, err) var de *Error require.ErrorAs(t, err, &de) @@ -90,7 +90,7 @@ func TestNewRegistration_CertFQDNMismatch(t *testing.T) { ep := []AgentEndpoint{{Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}} cert := &ByocServerCertificate{SubjectCommonName: "other.example.com"} - _, err := NewRegistration("a", "o", ansName, "", "", "", ep, cert, &csr, time.Now()) + _, err := NewRegistration("a", "o", ansName, "", "", "", ep, cert, &csr, nil, time.Now()) assert.ErrorIs(t, err, ErrCertificate) } @@ -223,7 +223,7 @@ func TestNewRegistration_InvalidEndpoint(t *testing.T) { badEndpoints := []AgentEndpoint{ {Protocol: ProtocolMCP, AgentURL: "https://other.example.com/mcp"}, } - _, err := NewRegistration("a", "o", ansName, "", "", "", badEndpoints, nil, &csr, time.Now()) + _, err := NewRegistration("a", "o", ansName, "", "", "", badEndpoints, nil, &csr, nil, time.Now()) require.Error(t, err) assert.ErrorIs(t, err, ErrValidation) } @@ -312,3 +312,101 @@ func TestRegistrationDetails_EffectiveTimestamp(t *testing.T) { // Original unchanged. assert.Equal(t, reg, d.EffectiveTimestamp()) } + +func TestNewRegistration_AnchorClaim_FQDNStoredOnAggregate(t *testing.T) { + // FQDN claim alongside a versioned registration: claim attaches + // to the aggregate; existing versioned-FQDN behavior is preserved. + ansName, err := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + require.NoError(t, err) + csr := NewIdentityCSR("csr-1", "-----BEGIN CSR-----", time.Now()) + endpoints := []AgentEndpoint{ + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + } + claim := &IdentityClaim{ + AnchorType: AnchorTypeFQDN, + ResolvedID: "agent.example.com", + PublicKeyJWK: []byte(`{"kty":"OKP"}`), + IssuedAt: time.Now().UTC(), + } + reg, err := NewRegistration( + "agent-uuid", "owner-1", ansName, "", "Agent", "", + endpoints, nil, &csr, claim, time.Now(), + ) + require.NoError(t, err) + require.NotNil(t, reg.AnchorClaim) + assert.Equal(t, AnchorTypeFQDN, reg.AnchorClaim.AnchorType) + assert.Equal(t, "agent.example.com", reg.AnchorClaim.ResolvedID) +} + +func TestNewRegistration_AnchorClaim_DIDForcedBaseOnly(t *testing.T) { + // A DID anchor claim accompanied by a versioned ansName is + // rejected: until ANS-2 admits non-FQDN URI SANs, DID + // registrations must take the base-only path. + ansName, err := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + require.NoError(t, err) + csr := NewIdentityCSR("csr-1", "-----BEGIN CSR-----", time.Now()) + endpoints := []AgentEndpoint{ + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + } + claim := &IdentityClaim{ + AnchorType: AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + PublicKeyJWK: []byte(`{"kty":"OKP"}`), + IssuedAt: time.Now().UTC(), + } + _, err = NewRegistration( + "agent-uuid", "owner-1", ansName, "agent.example.com", "Agent", "", + endpoints, nil, &csr, claim, time.Now(), + ) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "NON_FQDN_REQUIRES_BASE_ONLY") || + strings.Contains(err.Error(), "must be registered as base-only"), + "expected NON_FQDN_REQUIRES_BASE_ONLY, got %v", err) +} + +func TestNewRegistration_AnchorClaim_DIDBaseOnlyAccepted(t *testing.T) { + // DID + base-only is the only path admitted today: zero AnsName, + // nil CSR, AgentHost supplied (the service FQDN where the agent + // is reachable, distinct from the DID identity). + endpoints := []AgentEndpoint{ + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + } + claim := &IdentityClaim{ + AnchorType: AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + PublicKeyJWK: []byte(`{"kty":"OKP"}`), + IssuedAt: time.Now().UTC(), + } + reg, err := NewRegistration( + "agent-uuid", "owner-1", AnsName{}, "agent.example.com", "Agent", "", + endpoints, nil, nil, claim, time.Now(), + ) + require.NoError(t, err) + require.True(t, reg.IsBaseOnly()) + require.NotNil(t, reg.AnchorClaim) + assert.Equal(t, AnchorTypeDID, reg.AnchorClaim.AnchorType) + assert.Equal(t, "did:web:agent.example.com", reg.AnchorClaim.ResolvedID) + // AgentHost (service FQDN) and AnchorClaim.ResolvedID are intentionally + // distinct for non-FQDN anchors. + assert.Equal(t, "agent.example.com", reg.AgentHost) +} + +func TestNewRegistration_AnchorClaim_LEIBaseOnlyAccepted(t *testing.T) { + endpoints := []AgentEndpoint{ + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + } + claim := &IdentityClaim{ + AnchorType: AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + PublicKeyJWK: []byte(`{"kty":"OKP"}`), + IssuedAt: time.Now().UTC(), + } + reg, err := NewRegistration( + "agent-uuid", "owner-1", AnsName{}, "agent.example.com", "Agent", "", + endpoints, nil, nil, claim, time.Now(), + ) + require.NoError(t, err) + require.NotNil(t, reg.AnchorClaim) + assert.Equal(t, AnchorTypeLEI, reg.AnchorClaim.AnchorType) + assert.Equal(t, "529900T8BM49AURSDO55", reg.AnchorClaim.ResolvedID) +} diff --git a/internal/ra/handler/anchor_test.go b/internal/ra/handler/anchor_test.go new file mode 100644 index 0000000..49250a7 --- /dev/null +++ b/internal/ra/handler/anchor_test.go @@ -0,0 +1,148 @@ +package handler + +import ( + "errors" + "testing" + + "github.com/godaddy/ans/internal/domain" +) + +// TestResolveAnchorClaim_OmittedReturnsNil pins the legacy path: +// when the V2 register request has no anchor block, the handler +// returns a nil claim and the service falls back to FQDN-implicit +// behavior. +func TestResolveAnchorClaim_OmittedReturnsNil(t *testing.T) { + claim, err := resolveAnchorClaim(nil, "agent.example.com") + if err != nil { + t.Fatalf("err: %v", err) + } + if claim != nil { + t.Errorf("expected nil claim, got %+v", claim) + } +} + +func TestResolveAnchorClaim_MissingType(t *testing.T) { + _, err := resolveAnchorClaim(&anchorRequestDTO{Input: "agent.example.com"}, "agent.example.com") + if err == nil { + t.Fatal("expected INVALID_ANCHOR_TYPE, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "INVALID_ANCHOR_TYPE" { + t.Errorf("expected INVALID_ANCHOR_TYPE, got %v", err) + } +} + +func TestResolveAnchorClaim_UnknownType(t *testing.T) { + _, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "spiffe", + Input: "spiffe://example.org/foo", + }, "agent.example.com") + if err == nil { + t.Fatal("expected INVALID_ANCHOR_TYPE, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "INVALID_ANCHOR_TYPE" { + t.Errorf("expected INVALID_ANCHOR_TYPE, got %v", err) + } +} + +func TestResolveAnchorClaim_MissingInput(t *testing.T) { + _, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "fqdn", + Input: "", + }, "agent.example.com") + if err == nil { + t.Fatal("expected MISSING_ANCHOR_INPUT, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "MISSING_ANCHOR_INPUT" { + t.Errorf("expected MISSING_ANCHOR_INPUT, got %v", err) + } +} + +func TestResolveAnchorClaim_FQDNAgentHostMustMatch(t *testing.T) { + _, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "fqdn", + Input: "different.example.com", + }, "agent.example.com") + if err == nil { + t.Fatal("expected ANCHOR_INPUT_AGENT_HOST_MISMATCH, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "ANCHOR_INPUT_AGENT_HOST_MISMATCH" { + t.Errorf("expected ANCHOR_INPUT_AGENT_HOST_MISMATCH, got %v", err) + } +} + +func TestResolveAnchorClaim_FQDNCaseInsensitive(t *testing.T) { + claim, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "fqdn", + Input: "AGENT.example.com", + }, "agent.example.COM") + if err != nil { + t.Fatalf("expected case-insensitive match, got %v", err) + } + if claim.AnchorType != domain.AnchorTypeFQDN { + t.Errorf("AnchorType = %q", claim.AnchorType) + } +} + +func TestResolveAnchorClaim_DIDAcceptsArbitraryInput(t *testing.T) { + claim, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "did", + Input: "did:web:agent.example.com", + }, "agent.example.com") + if err != nil { + t.Fatalf("err: %v", err) + } + if claim.AnchorType != domain.AnchorTypeDID { + t.Errorf("AnchorType = %q", claim.AnchorType) + } + if claim.ResolvedID != "did:web:agent.example.com" { + t.Errorf("ResolvedID = %q", claim.ResolvedID) + } +} + +func TestResolveAnchorClaim_LEIAcceptsTwentyChar(t *testing.T) { + claim, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "lei", + Input: "529900T8BM49AURSDO55", + }, "agent.example.com") + if err != nil { + t.Fatalf("err: %v", err) + } + if claim.AnchorType != domain.AnchorTypeLEI { + t.Errorf("AnchorType = %q", claim.AnchorType) + } + if claim.ResolvedID != "529900T8BM49AURSDO55" { + t.Errorf("ResolvedID = %q", claim.ResolvedID) + } +} + +func TestAnchorFields_NilOrEmptyClaim(t *testing.T) { + at, ar := anchorFields(nil) + if at != "" || ar != "" { + t.Errorf("nil reg: anchor fields should be empty, got (%q, %q)", at, ar) + } + reg := &domain.AgentRegistration{} + at, ar = anchorFields(reg) + if at != "" || ar != "" { + t.Errorf("empty claim: anchor fields should be empty, got (%q, %q)", at, ar) + } +} + +func TestAnchorFields_PopulatedClaim(t *testing.T) { + reg := &domain.AgentRegistration{ + AnchorClaim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + }, + } + at, ar := anchorFields(reg) + if at != "did" { + t.Errorf("AnchorType = %q, want did", at) + } + if ar != "did:web:agent.example.com" { + t.Errorf("AnchorResolvedID = %q", ar) + } +} diff --git a/internal/ra/handler/dto.go b/internal/ra/handler/dto.go index 80fa70b..e7ceebb 100644 --- a/internal/ra/handler/dto.go +++ b/internal/ra/handler/dto.go @@ -20,12 +20,18 @@ type listResponse struct { } type listItem struct { - AgentID string `json:"agentId"` - AgentDisplayName string `json:"agentDisplayName"` - AgentDescription string `json:"agentDescription,omitempty"` - Version string `json:"version"` - AgentHost string `json:"agentHost"` - AnsName string `json:"ansName"` + AgentID string `json:"agentId"` + AgentDisplayName string `json:"agentDisplayName"` + AgentDescription string `json:"agentDescription,omitempty"` + Version string `json:"version"` + AgentHost string `json:"agentHost"` + AnsName string `json:"ansName"` + // AnchorType + AnchorResolvedID surface the registration's + // ANS-0 anchor profile. Both omitted for legacy FQDN-implicit + // rows; populated for any registration that came in through + // the anchor block on V2 register. + AnchorType string `json:"anchorType,omitempty"` + AnchorResolvedID string `json:"anchorResolvedId,omitempty"` Status string `json:"status"` TTL int `json:"ttl"` RegistrationTimestamp string `json:"registrationTimestamp,omitempty"` @@ -33,6 +39,16 @@ type listItem struct { Links []linkDTO `json:"links"` } +// anchorFields returns the (anchorType, anchorResolvedId) pair for +// a registration's DTO emission. Both empty when the registration +// has no AnchorClaim attached (legacy rows). +func anchorFields(reg *domain.AgentRegistration) (string, string) { + if reg == nil || reg.AnchorClaim == nil { + return "", "" + } + return string(reg.AnchorClaim.AnchorType), reg.AnchorClaim.ResolvedID +} + func mapListResponse(res *service.ListResult) listResponse { items := make([]listItem, 0, len(res.Items)) for _, reg := range res.Items { @@ -49,6 +65,7 @@ func mapListResponse(res *service.ListResult) listResponse { if !reg.IsBaseOnly() { version = reg.AnsName.Version().String() } + anchorType, anchorResolved := anchorFields(reg) items = append(items, listItem{ AgentID: reg.AgentID, AgentDisplayName: reg.Details.DisplayName, @@ -56,6 +73,8 @@ func mapListResponse(res *service.ListResult) listResponse { Version: version, AgentHost: reg.FQDN(), AnsName: reg.AnsName.String(), + AnchorType: anchorType, + AnchorResolvedID: anchorResolved, Status: string(reg.Status), TTL: 300, RegistrationTimestamp: reg.Details.RegistrationTimestamp.Format("2006-01-02T15:04:05Z07:00"), @@ -82,12 +101,17 @@ func mapListResponse(res *service.ListResult) listResponse { // ----- Detail DTO (matches V2 spec AgentDetails §1086) ----- type agentDetails struct { - AgentID string `json:"agentId"` - AgentDisplayName string `json:"agentDisplayName"` - AgentDescription string `json:"agentDescription,omitempty"` - Version string `json:"version"` - AgentHost string `json:"agentHost"` - AnsName string `json:"ansName"` + AgentID string `json:"agentId"` + AgentDisplayName string `json:"agentDisplayName"` + AgentDescription string `json:"agentDescription,omitempty"` + Version string `json:"version"` + AgentHost string `json:"agentHost"` + AnsName string `json:"ansName"` + // AnchorType + AnchorResolvedID surface the registration's + // ANS-0 anchor profile. Both omitted for legacy FQDN-implicit + // registrations. + AnchorType string `json:"anchorType,omitempty"` + AnchorResolvedID string `json:"anchorResolvedId,omitempty"` AgentStatus string `json:"agentStatus"` Endpoints []endpointDTO `json:"endpoints"` RegistrationTimestamp string `json:"registrationTimestamp,omitempty"` @@ -112,6 +136,7 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { if !reg.IsBaseOnly() { version = reg.AnsName.Version().String() } + anchorType, anchorResolved := anchorFields(reg) d := agentDetails{ AgentID: reg.AgentID, AgentDisplayName: reg.Details.DisplayName, @@ -119,6 +144,8 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { Version: version, AgentHost: reg.FQDN(), AnsName: reg.AnsName.String(), + AnchorType: anchorType, + AnchorResolvedID: anchorResolved, AgentStatus: string(reg.Status), Endpoints: mapEndpointsToDTO(res.Endpoints), RegistrationTimestamp: reg.Details.RegistrationTimestamp.Format("2006-01-02T15:04:05Z07:00"), diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index df79891..ffe11f1 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -61,6 +61,27 @@ type registrationRequest struct { // value rejected with 422 INVALID_DNS_RECORD_STYLE. See // ANS_SPEC.md §4.4.2 for record-shape semantics. DNSRecordStyle string `json:"dnsRecordStyle,omitempty"` + + // Anchor optionally selects which ANS-0 anchor profile the + // caller wants applied. When omitted, the registration takes + // the legacy FQDN-implicit path (anchorType inferred from + // AgentHost). When present, the named profile resolver runs + // against AnchorInput and the resulting IdentityClaim attaches + // to the aggregate. Non-FQDN profiles (DID, LEI) force base- + // only registration invariants per the proposal at + // docs/proposals/2026-05-16-spec-skeletons/ans-0-identity-anchor.md. + Anchor *anchorRequestDTO `json:"anchor,omitempty"` +} + +// anchorRequestDTO is the V2 register request's anchor block. +// Slice 6 ships the wire shape; Slice 7+ wires the SDK and +// the actual resolver dispatch in the service layer. +type anchorRequestDTO struct { + // AnchorType is one of "fqdn", "did", "lei". Empty rejected. + AnchorType string `json:"anchorType"` + // Input is the anchor-resolver input string: an FQDN, a DID + // URI (did:web:...), or an ISO 17442 LEI. + Input string `json:"input"` } type endpointDTO struct { @@ -159,6 +180,12 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { return } + anchorClaim, err := resolveAnchorClaim(req.Anchor, req.AgentHost) + if err != nil { + WriteError(w, err) + return + } + resp, err := h.svc.RegisterAgent(r.Context(), service.RegisterRequest{ OwnerID: id.Subject, AnsName: ansName, @@ -166,6 +193,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { DisplayName: req.AgentDisplayName, Description: req.AgentDescription, Endpoints: eps, + AnchorClaim: anchorClaim, IdentityCSRPEM: req.IdentityCSRPEM, ServerCsrPEM: req.ServerCsrPEM, ServerCertificatePEM: req.ServerCertificatePEM, @@ -329,3 +357,72 @@ func resolveAnsNameForRegister(req *registrationRequest) (domain.AnsName, error) } return domain.NewAnsName(semver, req.AgentHost) } + +// resolveAnchorClaim translates the optional anchor block in the +// V2 register request into a verified domain.IdentityClaim that +// the service layer attaches to the aggregate. +// +// Slice 6 keeps the handler scope tight: it accepts the wire shape, +// validates the anchorType + input pair, and constructs an +// IdentityClaim with PublicKeyJWK left empty (a sentinel meaning +// "claim was not produced by an AnchorResolver"). Slice 7+ wires +// the SDK; subsequent slices will plumb a real +// port.AnchorResolver into the service layer so the handler-side +// shortcut goes away. For now, the handler-side claim is a typed +// breadcrumb the storage layer persists so V2 list/detail +// responses can surface the anchor type. +// +// Validation rules: +// - Anchor block omitted: returns nil claim; service falls back +// to legacy FQDN-implicit behavior. +// - Anchor block present with empty anchorType: returns +// INVALID_ANCHOR_TYPE. +// - Anchor block present with empty input: returns +// MISSING_ANCHOR_INPUT. +// - anchorType "fqdn" with input that does not match agentHost: +// returns ANCHOR_INPUT_AGENT_HOST_MISMATCH. The two fields +// must agree for the FQDN profile. +// - anchorType "did" or "lei": input is accepted as-is (lexical +// validation belongs to the resolver, which lands in a later +// slice; today the input is a typed breadcrumb). +func resolveAnchorClaim(anchor *anchorRequestDTO, agentHost string) (*domain.IdentityClaim, error) { + if anchor == nil { + return nil, nil //nolint:nilnil // (nil, nil) is the documented "no anchor block" signal + } + anchorType := strings.TrimSpace(strings.ToLower(anchor.AnchorType)) + input := strings.TrimSpace(anchor.Input) + switch anchorType { + case "": + return nil, domain.NewValidationError( + "INVALID_ANCHOR_TYPE", + "anchor.anchorType is required when anchor block is present", + ) + case string(domain.AnchorTypeFQDN), string(domain.AnchorTypeDID), string(domain.AnchorTypeLEI): + default: + return nil, domain.NewValidationError( + "INVALID_ANCHOR_TYPE", + "anchor.anchorType must be one of fqdn, did, lei (got "+anchorType+")", + ) + } + if input == "" { + return nil, domain.NewValidationError( + "MISSING_ANCHOR_INPUT", + "anchor.input is required when anchor block is present", + ) + } + // FQDN profile: input MUST match agentHost (case-insensitive). + // The two fields name the same operational identity, so a + // divergence is operator error caught at the boundary. + if anchorType == string(domain.AnchorTypeFQDN) { + if !strings.EqualFold(strings.TrimSpace(agentHost), input) { + return nil, domain.NewValidationError( + "ANCHOR_INPUT_AGENT_HOST_MISMATCH", + "anchor.input must match agentHost for the FQDN profile", + ) + } + } + return &domain.IdentityClaim{ + AnchorType: domain.AnchorType(anchorType), + ResolvedID: input, + }, nil +} diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index d47e7e8..2777082 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -102,6 +102,18 @@ type RegisterRequest struct { // surfaces as INVALID_DNS_RECORD_STYLE before the aggregate is // created. DNSRecordStyle domain.DNSRecordStyle + + // AnchorClaim is the verified IdentityClaim produced by an ANS-0 + // AnchorResolver when the caller registered through the anchor- + // aware path. Nil for legacy FQDN-only callers; the service + // passes it through to domain.NewRegistration verbatim. Non-FQDN + // claims force base-only invariants until ANS-2 admits non-FQDN + // URI SANs (NewRegistration enforces this; NON_FQDN_REQUIRES_BASE_ONLY + // fires when an anchored versioned registration is submitted). + // + // Slice 5a stores AnchorClaim only in-memory; persistence lands + // in Slice 5b through migration 009. + AnchorClaim *domain.IdentityClaim } // RegisterResponse is returned to the HTTP handler after a successful @@ -341,7 +353,7 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq reg, err := domain.NewRegistration( agentID, req.OwnerID, req.AnsName, req.AgentHost, req.DisplayName, req.Description, - req.Endpoints, byocCert, csrPtr, now, + req.Endpoints, byocCert, csrPtr, req.AnchorClaim, now, ) if err != nil { return nil, err