api/v1: build responses from view accessors, not AsStruct

Serialize node, user and pre-auth-key responses through the NodeView,
UserView and PreAuthKeyView accessors instead of AsStruct(), which clones
the whole record on every read. Document the convention in AGENTS.md.
This commit is contained in:
Kristoffer Dalby 2026-06-20 20:16:51 +00:00
parent 339cb392a9
commit 96d736ec23
5 changed files with 66 additions and 61 deletions

View file

@ -275,6 +275,11 @@ Key reminders:
`e = e.Str("k", v)`. Forgetting to reassign silently drops the field. `e = e.Str("k", v)`. Forgetting to reassign silently drops the field.
- **Tests**: prefer `hscontrol/servertest/` for server-level tests that - **Tests**: prefer `hscontrol/servertest/` for server-level tests that
don't need Docker — faster than full integration tests. don't need Docker — faster than full integration tests.
- **View types in read paths**: response serializers must read through
`NodeView`/`UserView`/`PreAuthKeyView` accessors. `AsStruct()` clones the
whole record on every read — it is only for DB-write/merge clones and mutable
working copies, never to build an API response. `grep AsStruct hscontrol/api`
must come back empty.
## Gotchas ## Gotchas

View file

@ -223,7 +223,7 @@ func registerNodeReadOps(api huma.API, b Backend) {
// Tags-as-identity: tagged nodes are presented as the special // Tags-as-identity: tagged nodes are presented as the special
// TaggedDevices user. // TaggedDevices user.
if node.IsTagged() { if node.IsTagged() {
user := userFromState(&types.TaggedDevices) user := userFromView(types.TaggedDevices.View())
n.User = &user n.User = &user
} }
@ -583,74 +583,73 @@ func registerNodeAdminOps(api huma.API, b Backend) {
}) })
} }
// nodeFromView builds the Node response from a NodeView. SubnetRoutes is left // nodeFromView builds the Node response from a NodeView, reading through the
// empty; callers that serve routes set it explicitly. // view accessors. SubnetRoutes is left empty; callers that serve routes set it
// explicitly.
func nodeFromView(view types.NodeView) Node { func nodeFromView(view types.NodeView) Node {
node := view.AsStruct()
n := Node{ n := Node{
ID: formatID(uint64(node.ID)), ID: view.StringID(),
MachineKey: node.MachineKey.String(), MachineKey: view.MachineKey().String(),
NodeKey: node.NodeKey.String(), NodeKey: view.NodeKey().String(),
DiscoKey: node.DiscoKey.String(), DiscoKey: view.DiscoKey().String(),
IPAddresses: nonNilStrings(node.IPsAsString()), IPAddresses: nonNilStrings(view.IPsAsString()),
Name: node.Hostname, Name: view.Hostname(),
CreatedAt: node.CreatedAt, CreatedAt: view.CreatedAt(),
RegisterMethod: registerMethodEnum(node.RegisterMethod), RegisterMethod: registerMethodEnum(view.RegisterMethod()),
GivenName: node.GivenName, GivenName: view.GivenName(),
Online: node.IsOnline != nil && *node.IsOnline, Online: view.IsOnline().Valid() && view.IsOnline().Get(),
ApprovedRoutes: nonNilStrings(util.PrefixesToString(node.ApprovedRoutes)), ApprovedRoutes: nonNilStrings(util.PrefixesToString(view.ApprovedRoutes().AsSlice())),
AvailableRoutes: nonNilStrings(util.PrefixesToString(node.AnnouncedRoutes())), AvailableRoutes: nonNilStrings(util.PrefixesToString(view.AnnouncedRoutes())),
SubnetRoutes: []string{}, SubnetRoutes: []string{},
Tags: nonNilStrings(node.Tags), Tags: nonNilStrings(view.Tags().AsSlice()),
} }
if node.User != nil { if view.User().Valid() {
user := userFromState(node.User) user := userFromView(view.User())
n.User = &user n.User = &user
} }
if node.AuthKey != nil { if view.AuthKey().Valid() {
n.PreAuthKey = nodePreAuthKeyFromState(node.AuthKey) n.PreAuthKey = nodePreAuthKeyFromView(view.AuthKey())
} }
if node.LastSeen != nil { if view.LastSeen().Valid() {
ls := *node.LastSeen ls := view.LastSeen().Get()
n.LastSeen = &ls n.LastSeen = &ls
} }
if node.Expiry != nil { if view.Expiry().Valid() {
exp := *node.Expiry exp := view.Expiry().Get()
n.Expiry = &exp n.Expiry = &exp
} }
return n return n
} }
// nodePreAuthKeyFromState builds the embedded NodePreAuthKey, masking the key to // nodePreAuthKeyFromView builds the embedded NodePreAuthKey, masking the key to
// its prefix (legacy plaintext keys are shown in full). // its prefix (legacy plaintext keys are shown in full).
func nodePreAuthKeyFromState(key *types.PreAuthKey) *NodePreAuthKey { func nodePreAuthKeyFromView(key types.PreAuthKeyView) *NodePreAuthKey {
pak := &NodePreAuthKey{ pak := &NodePreAuthKey{
ID: formatID(key.ID), ID: formatID(key.ID()),
Key: maskedPreAuthKey(key), Key: maskedPreAuthKey(key),
Reusable: key.Reusable, Reusable: key.Reusable(),
Ephemeral: key.Ephemeral, Ephemeral: key.Ephemeral(),
Used: key.Used, Used: key.Used(),
AclTags: nonNilStrings(key.Tags), AclTags: nonNilStrings(key.Tags().AsSlice()),
} }
if key.User != nil { if key.User().Valid() {
user := userFromState(key.User) user := userFromView(key.User())
pak.User = &user pak.User = &user
} }
if key.Expiration != nil { if key.Expiration().Valid() {
exp := *key.Expiration exp := key.Expiration().Get()
pak.Expiration = &exp pak.Expiration = &exp
} }
if key.CreatedAt != nil { if key.CreatedAt().Valid() {
created := *key.CreatedAt created := key.CreatedAt().Get()
pak.CreatedAt = &created pak.CreatedAt = &created
} }

View file

@ -223,7 +223,7 @@ func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey {
} }
if key.User != nil { if key.User != nil {
u := userFromState(key.User) u := userFromView(key.User.View())
out.User = &u out.User = &u
} }
@ -243,7 +243,7 @@ func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey {
func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey { func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
out := PreAuthKey{ out := PreAuthKey{
ID: formatID(key.ID), ID: formatID(key.ID),
Key: maskedPreAuthKey(key), Key: maskedPreAuthKey(key.View()),
Reusable: key.Reusable, Reusable: key.Reusable,
Ephemeral: key.Ephemeral, Ephemeral: key.Ephemeral,
Used: key.Used, Used: key.Used,
@ -251,7 +251,7 @@ func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
} }
if key.User != nil { if key.User != nil {
u := userFromState(key.User) u := userFromView(key.User.View())
out.User = &u out.User = &u
} }
@ -269,12 +269,12 @@ func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey {
// maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is // maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is
// never returned; legacy plaintext keys are returned in full for backwards // never returned; legacy plaintext keys are returned in full for backwards
// compatibility. // compatibility.
func maskedPreAuthKey(key *types.PreAuthKey) string { func maskedPreAuthKey(key types.PreAuthKeyView) string {
if key.Prefix != "" { if key.Prefix() != "" {
return "hskey-auth-" + key.Prefix + "-***" return "hskey-auth-" + key.Prefix() + "-***"
} }
return key.Key return key.Key()
} }
// nonNilTags ensures aclTags serializes as [] rather than null, matching // nonNilTags ensures aclTags serializes as [] rather than null, matching

View file

@ -29,23 +29,24 @@ type User struct {
ProfilePicURL string `json:"profilePicUrl"` ProfilePicURL string `json:"profilePicUrl"`
} }
// userFromState converts a domain user into the v1 response shape: Name falls // userFromView converts a domain user into the v1 response shape, reading
// back to Username() (email/provider/id) when the stored Name is empty, so OIDC // through the [types.UserView] accessors: Name falls back to Username()
// users display their email. // (email/provider/id) when the stored Name is empty, so OIDC users display
func userFromState(u *types.User) User { // their email.
name := u.Name func userFromView(u types.UserView) User {
name := u.Name()
if name == "" { if name == "" {
name = u.Username() name = u.Username()
} }
return User{ return User{
ID: formatID(u.ID), ID: formatID(u.ID()),
Name: name, Name: name,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt(),
DisplayName: u.DisplayName, DisplayName: u.DisplayName(),
Email: u.Email, Email: u.Email(),
ProviderID: u.ProviderIdentifier.String, ProviderID: u.ProviderIdentifier().String,
Provider: u.Provider, Provider: u.Provider(),
ProfilePicURL: u.ProfilePicURL, ProfilePicURL: u.ProfilePicURL(),
} }
} }

View file

@ -93,7 +93,7 @@ func registerUsers(api huma.API, b Backend) {
b.Change(policyChanged) b.Change(policyChanged)
out := &userOutput{} out := &userOutput{}
out.Body.User = userFromState(user) out.Body.User = userFromView(user.View())
return out, nil return out, nil
}) })
@ -129,7 +129,7 @@ func registerUsers(api huma.API, b Backend) {
} }
out := &userOutput{} out := &userOutput{}
out.Body.User = userFromState(newUser) out.Body.User = userFromView(newUser.View())
return out, nil return out, nil
}) })
@ -192,7 +192,7 @@ func registerUsers(api huma.API, b Backend) {
out.Body.Users = make([]User, len(users)) out.Body.Users = make([]User, len(users))
for i := range users { for i := range users {
out.Body.Users[i] = userFromState(&users[i]) out.Body.Users[i] = userFromView(users[i].View())
} }
return out, nil return out, nil