Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
3478079
feat: add AuthTypeSeedphrase, seedphrase hashing, and username genera…
Ryanmello07 Jul 15, 2026
f707f13
feat: add seedphrase auth model (login, generate, regenerate)
Ryanmello07 Jul 15, 2026
eec1404
feat: add RemoveAuth and countAuthMethods
Ryanmello07 Jul 15, 2026
9b4cf42
feat: add seedphrase signup path, remove GuestMode from NetworkCreate…
Ryanmello07 Jul 15, 2026
6497c1f
feat: seedphrase login branch + export ValidateNetworkName + remove g…
Ryanmello07 Jul 15, 2026
d38393e
feat: remove guest upgrade controllers
Ryanmello07 Jul 15, 2026
3becfa4
feat: add seedphrase, account, and auth binding controllers
Ryanmello07 Jul 15, 2026
340d828
feat: add seedphrase/account auth routes and remove guest mode gating…
Ryanmello07 Jul 15, 2026
f2043c1
test: add seedphrase auth, login, regenerate, and RemoveAuth tests
Ryanmello07 Jul 15, 2026
401aa25
feat: add seedphrase auth, network name reclaim, and rate-limit migra…
Ryanmello07 Jul 16, 2026
d2025bc
fix: return user-facing errors as JSON not HTTP 500 + add seedphrase …
Ryanmello07 Jul 16, 2026
d83a96f
chore: gofmt fixup after seedphrase cherry-picks
Ryanmello07 Jul 16, 2026
061e8d6
fix: require email/SSO bound before seedphrase users can claim/change…
Ryanmello07 Jul 16, 2026
3817e38
fix: require VERIFIED email (not just any email) for name change/claim
Ryanmello07 Jul 16, 2026
4f9439b
feat: add bulk delete endpoint for offline network clients
full-bars Jul 15, 2026
b2d123f
address review findings: nil-guard, comment clarity, tighter cap, tes…
full-bars Jul 15, 2026
4c2e892
Potential fix for pull request finding
Ryanmello07 Jul 16, 2026
3e10828
add tests for nil-ByJwt guard, sync/cap boundaries, and Post reschedu…
full-bars Jul 15, 2026
62e7915
fix: address review findings — body cap, dedup, nil-guard, zero taskI…
Ryanmello07 Jul 16, 2026
9270c67
fix: update router tests and bulk-delete handler for guest-mode removal
Ryanmello07 Jul 16, 2026
604f76d
fix: update spec_conformance_test.go for seedphrase/account routes
Ryanmello07 Jul 16, 2026
e50d662
chore: remove dead UpgradeGuest* result types
Ryanmello07 Jul 16, 2026
1659a96
fix: properly return error when adding duplicate auth method
Ryanmello07 Jul 17, 2026
69bb8cb
fix: clear wallet_address on network_user when removing wallet auth
Ryanmello07 Jul 17, 2026
0e86f59
fix: update network_user.auth_type when removing an auth method
Ryanmello07 Jul 18, 2026
c0b55b3
feat: add seedphrase_auths and composite auth_types to GET /network/user
Ryanmello07 Jul 18, 2026
b3d16bc
fix: close RemoveAuth TOCTOU account-lockout race and unknown-auth-ty…
Ryanmello07 Jul 18, 2026
cdf46a9
fix: mark seedphrase and wallet logins as successful auth attempts
Ryanmello07 Jul 18, 2026
87ca4ad
fix: guard LoginWithSeedphrase against orphaned auth rows
Ryanmello07 Jul 18, 2026
25504bb
fix: delete orphaned seedphrase auth rows on network removal
Ryanmello07 Jul 18, 2026
fd58906
fix: network_client_model_test.go compile break + zero-UUID boundary …
Ryanmello07 Jul 18, 2026
65b0dc0
fix: TestNetworkUser compile break + mismatched guest-era assertions
Ryanmello07 Jul 18, 2026
37eb4cd
fix: use assert.* not connect.* in TestNetworkUser's second block
Ryanmello07 Jul 18, 2026
2187929
fix: remove unreachable SSO-binding branch in networkCreateSeedphrase
Ryanmello07 Jul 18, 2026
3cb03fb
fix: don't put a user's own current name into reclaim cooldown
Ryanmello07 Jul 18, 2026
b6bbf07
fix: repair test-suite compile breaks left over from guest-mode removal
Ryanmello07 Jul 18, 2026
4bfa00f
fix: don't panic on wallet-address unique-index conflict in addWallet…
Ryanmello07 Jul 18, 2026
307be75
fix: allow wallet-only users to claim/change their network name
Ryanmello07 Jul 19, 2026
67e27a0
fix: JWT refresh carried stale network name, and duplicate wallet bin…
Ryanmello07 Jul 19, 2026
9c148a6
Merge urnetwork/server:main (perf and correctness fixes wave) into fe…
Ryanmello07 Jul 21, 2026
d22daca
Add account-based daily rate limits for auth-method and name changes
Ryanmello07 Jul 21, 2026
ca9e8ac
Add tests for account_action_rate_limit.go
Ryanmello07 Jul 21, 2026
c62ca33
Fix rate-limit error message leak and ClaimNetworkName/ChangeNetworkN…
Ryanmello07 Jul 21, 2026
28dfd9f
Fix legacy guest account privilege check to use live auth-method state
Ryanmello07 Jul 21, 2026
6302a5b
Add abuse limits for the bulk client removal API
Ryanmello07 Jul 22, 2026
2b39260
Fix timezone-dependent skew in account action rate-limit window check
Ryanmello07 Jul 22, 2026
aab97fe
Fix bulk-delete quota being charged before async admission gates
Ryanmello07 Jul 22, 2026
01bb09c
Queue bulk-delete requests past the hourly budget instead of rejectin…
Ryanmello07 Jul 22, 2026
71dea27
Fix thundering-herd risk in deferred bulk-delete tasks; scope concurr…
Ryanmello07 Jul 22, 2026
4d82fce
Add test proving jitter actually spreads deferred requests
Ryanmello07 Jul 22, 2026
ddde024
Merge remote-tracking branch 'origin/main' into feat/seedphrase-to-up…
Ryanmello07 Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,13 @@ func Routes() []*router.Route {
router.NewRoute("POST", "/auth/network-delete", handlers.RemoveNetwork),
router.NewRoute("POST", "/auth/code-create", handlers.AuthCodeCreate),
router.NewRoute("POST", "/auth/code-login", handlers.AuthCodeLogin),
router.NewRoute("POST", "/auth/upgrade-guest", handlers.UpgradeGuest),
router.NewRoute("POST", "/auth/upgrade-guest-existing", handlers.UpgradeGuestExisting),
router.NewRoute("POST", "/auth/add-auth", handlers.AuthAdd),
router.NewRoute("POST", "/auth/remove-auth", handlers.AuthRemove),
router.NewRoute("POST", "/auth/regenerate-seedphrase", handlers.AuthRegenerateSeedphrase),
router.NewRoute("POST", "/auth/generate-seedphrase", handlers.AuthGenerateSeedphrase),
router.NewRoute("POST", "/network/auth-client", handlers.AuthNetworkClient),
router.NewRoute("POST", "/network/remove-client", handlers.RemoveNetworkClient),
router.NewRoute("POST", "/network/remove-clients", handlers.RemoveNetworkClients),
router.NewRoute("GET", "/network/clients", handlers.NetworkClients),
router.NewRoute("GET", "/network/peers", handlers.NetworkPeers),
router.NewRoute("GET", "/network/provider-locations", handlers.NetworkGetProviderLocations),
Expand Down Expand Up @@ -132,6 +135,8 @@ func Routes() []*router.Route {
router.NewRoute("GET", "/account/referral-network", handlers.GetReferralNetwork),
router.NewRoute("GET", "/account/unlink-referral-network", handlers.UnlinkReferralNetwork),
router.NewRoute("POST", "/account/set-referral", handlers.SetNetworkReferral),
router.NewRoute("POST", "/account/change-name", handlers.ChangeNetworkName),
router.NewRoute("POST", "/account/claim-name", handlers.ClaimNetworkName),
router.NewRoute("GET", "/account/points", handlers.GetAccountPoints),
router.NewRoute("GET", "/account/balance-codes", handlers.GetNetworkRedeemedBalanceCodes),
router.NewRoute("POST", "/referral-code/validate", handlers.ValidateReferralCode),
Expand Down
8 changes: 8 additions & 0 deletions api/handlers/account_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,11 @@ func UnlinkReferralNetwork(w http.ResponseWriter, r *http.Request) {
func GetNetworkRedeemedBalanceCodes(w http.ResponseWriter, r *http.Request) {
router.WrapRequireAuth(controller.GetNetworkRedeemedBalanceCodes, w, r)
}

func ChangeNetworkName(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(controller.ChangeNetworkName, w, r)
}

func ClaimNetworkName(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(controller.ClaimNetworkName, w, r)
}
8 changes: 8 additions & 0 deletions api/handlers/auth_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,11 @@ func AuthRefreshToken(w http.ResponseWriter, r *http.Request) {
// router.WrapWithInputNoAuth(controller.AuthRefreshToken, w, r)
router.WrapRequireAuth(controller.RefreshToken, w, r)
}

func AuthAdd(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(controller.AddAuth, w, r)
}

func AuthRemove(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(controller.RemoveAuth, w, r)
}
5 changes: 5 additions & 0 deletions api/handlers/network_client_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func RemoveNetworkClient(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(model.RemoveNetworkClient, w, r)
}

func RemoveNetworkClients(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 100<<20) // 100 MB cap (allows ~2M UUIDs worst case)
router.WrapWithInputRequireAuth(model.RemoveNetworkClients, w, r)
}

func RemoveNetwork(w http.ResponseWriter, r *http.Request) {
router.WrapRequireAuth(controller.NetworkRemove, w, r)
}
Expand Down
8 changes: 0 additions & 8 deletions api/handlers/network_user_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,3 @@ import (
func GetNetworkUser(w http.ResponseWriter, r *http.Request) {
router.WrapRequireAuth(controller.GetNetworkUser, w, r)
}

func UpgradeGuest(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(controller.UpgradeFromGuest, w, r)
}

func UpgradeGuestExisting(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(controller.UpgradeFromGuestExisting, w, r)
}
16 changes: 16 additions & 0 deletions api/handlers/seedphrase_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package handlers

import (
"net/http"

"github.com/urnetwork/server/controller"
"github.com/urnetwork/server/router"
)

func AuthRegenerateSeedphrase(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(controller.RegenerateSeedphrase, w, r)
}

func AuthGenerateSeedphrase(w http.ResponseWriter, r *http.Request) {
router.WrapWithInputRequireAuth(controller.GenerateSeedphrase, w, r)
}
8 changes: 6 additions & 2 deletions api/spec_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ func registry() []specEndpoint {
{"POST", "/auth/network-delete", nil, rt(controller.NetworkRemoveResult{})},
{"POST", "/auth/code-create", rt(model.AuthCodeCreateArgs{}), rt(model.AuthCodeCreateResult{})},
{"POST", "/auth/code-login", rt(model.AuthCodeLoginArgs{}), rt(model.AuthCodeLoginResult{})},
{"POST", "/auth/upgrade-guest", rt(model.UpgradeGuestArgs{}), rt(model.UpgradeGuestResult{})},
{"POST", "/auth/upgrade-guest-existing", rt(model.UpgradeGuestExistingArgs{}), rt(model.UpgradeGuestExistingResult{})},
{"POST", "/auth/add-auth", rt(controller.AddAuthArgs{}), rt(controller.AddAuthResult{})},
{"POST", "/auth/remove-auth", rt(controller.RemoveAuthArgs{}), rt(controller.RemoveAuthResult{})},
{"POST", "/auth/regenerate-seedphrase", rt(controller.RegenerateSeedphraseArgs{}), rt(controller.RegenerateSeedphraseResult{})},
{"POST", "/auth/generate-seedphrase", rt(controller.GenerateSeedphraseArgs{}), rt(controller.GenerateSeedphraseResult{})},

{"POST", "/network/auth-client", rt(model.AuthNetworkClientArgs{}), rt(model.AuthNetworkClientResult{})},
{"POST", "/network/remove-client", rt(model.RemoveNetworkClientArgs{}), rt(model.RemoveNetworkClientResult{})},
Expand Down Expand Up @@ -146,6 +148,8 @@ func registry() []specEndpoint {
{"POST", "/account/wallets/verify-seeker", rt(controller.VerifySeekerNftHolderArgs{}), rt(controller.VerifySeekerNftHolderResult{})},
{"GET", "/account/balance-codes", nil, rt(controller.GetNetworkRedeemedBalanceCodesResult{})},
{"GET", "/account/referral-code", nil, rt(controller.NetworkReferralResult{})},
{"POST", "/account/change-name", rt(controller.ChangeNetworkNameArgs{}), rt(controller.ChangeNetworkNameResult{})},
{"POST", "/account/claim-name", rt(controller.ClaimNetworkNameArgs{}), rt(controller.ClaimNetworkNameResult{})},
{"POST", "/referral-code/validate", rt(controller.ValidateReferralCodeArgs{}), rt(controller.ValidateNetworkReferralCodeResult{})},
{"GET", "/account/referral-network", nil, rt(controller.GetNetworkReferralResult{})},
{"GET", "/account/unlink-referral-network", nil, rt(controller.UnlinkReferralNetworkResult{})},
Expand Down
259 changes: 259 additions & 0 deletions controller/account_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
package controller

import (
"context"
"fmt"
"time"

"github.com/urnetwork/server"
"github.com/urnetwork/server/model"
"github.com/urnetwork/server/session"
)

const networkNameReclaimCooldown = 24 * time.Hour

type ChangeNetworkNameArgs struct {
NetworkName string `json:"network_name"`
NewName string `json:"new_name"`
}

type ChangeNetworkNameError struct {
Message string `json:"message"`
}

type ChangeNetworkNameResult struct {
NetworkName string `json:"network_name"`
Error *ChangeNetworkNameError `json:"error,omitempty"`
}

type ClaimNetworkNameArgs = ChangeNetworkNameArgs
type ClaimNetworkNameError = ChangeNetworkNameError
type ClaimNetworkNameResult = ChangeNetworkNameResult

func ChangeNetworkName(
args ChangeNetworkNameArgs,
session *session.ClientSession,
) (*ChangeNetworkNameResult, error) {
return changeNetworkName(args, session, true)
}

func ClaimNetworkName(
args ClaimNetworkNameArgs,
session *session.ClientSession,
) (*ClaimNetworkNameResult, error) {
return changeNetworkName(args, session, false)
}

func changeNetworkName(
args ChangeNetworkNameArgs,
session *session.ClientSession,
reclaimCooldown bool,
) (*ChangeNetworkNameResult, error) {
// Seedphrase users must have email/phone, SSO, or a wallet bound to
// claim/change name — a signed wallet challenge is itself proof the
// user controls that identity, same as a verified email or SSO login.
if err := requireVerifiedIdentityBound(session.Ctx, session.ByJwt.UserId); err != nil {
return &ChangeNetworkNameResult{
Error: &ChangeNetworkNameError{
Message: err.Error(),
},
}, nil
}

// Accept either network_name or new_name field
name := args.NetworkName
if name == "" {
name = args.NewName
}
normalizedName, err := model.ValidateNetworkName(name)
if err != nil {
return &ChangeNetworkNameResult{
Error: &ChangeNetworkNameError{
Message: err.Error(),
},
}, nil
}

available, err := isNetworkNameAvailableForUser(session.Ctx, normalizedName, session.ByJwt.UserId)
if err != nil {
return nil, err
}
if !available {
return &ChangeNetworkNameResult{
Error: &ChangeNetworkNameError{
Message: "Network name not available.",
},
}, nil
}

// Claim (first-time name set, reachable once per account) and change
// (renaming an existing name) get separate daily budgets: bundling them
// would let a normal onboarding claim consume half of a new user's
// same-day rename budget before they've even picked a real name.
rateLimitAction := model.AccountActionClaimNetworkName
rateLimitDailyLimit := model.AccountActionClaimNetworkNameDailyLimit
if reclaimCooldown {
rateLimitAction = model.AccountActionChangeNetworkName
rateLimitDailyLimit = model.AccountActionChangeNetworkNameDailyLimit
}
if err := model.CheckAndRecordAccountActionRateLimit(
session.Ctx,
session.ByJwt.UserId,
rateLimitAction,
rateLimitDailyLimit,
model.AccountActionDailyWindow,
); err != nil {
return &ChangeNetworkNameResult{
Error: &ChangeNetworkNameError{
Message: err.Error(),
},
}, nil
}

var oldName *string
server.Db(session.Ctx, func(conn server.PgConn) {
result, err := conn.Query(
session.Ctx,
`
SELECT network_name FROM network
WHERE admin_user_id = $1
`,
session.ByJwt.UserId,
)
server.WithPgResult(result, err, func() {
if result.Next() {
var name string
server.Raise(result.Scan(&name))
oldName = &name
}
})
})

server.Tx(session.Ctx, func(tx server.PgTx) {
if reclaimCooldown && oldName != nil && *oldName != normalizedName {
coolDownUntil := server.NowUtc().Add(networkNameReclaimCooldown)
server.RaisePgResult(tx.Exec(
session.Ctx,
`
INSERT INTO network_name_reclaim (old_name, cool_down_until)
VALUES ($1, $2)
ON CONFLICT (old_name) DO UPDATE
SET cool_down_until = EXCLUDED.cool_down_until
`,
*oldName,
coolDownUntil,
))
}

server.RaisePgResult(tx.Exec(
session.Ctx,
`
UPDATE network
SET network_name = $2
WHERE admin_user_id = $1
`,
session.ByJwt.UserId,
normalizedName,
))
})

return &ChangeNetworkNameResult{
NetworkName: normalizedName,
}, nil
}

func isNetworkNameAvailableForUser(
ctx context.Context,
name string,
userId server.Id,
) (bool, error) {
available := true
reasonErr := error(nil)

server.Db(ctx, func(conn server.PgConn) {
// check if the name is taken by another network
result, err := conn.Query(
ctx,
`
SELECT admin_user_id FROM network
WHERE network_name = $1
`,
name,
)
server.WithPgResult(result, err, func() {
if result.Next() {
var owner server.Id
server.Raise(result.Scan(&owner))
if owner != userId {
available = false
}
}
})

if !available {
return
}

// check if the name is in cooldown (network_name_reclaim)
result, err = conn.Query(
ctx,
`
SELECT cool_down_until FROM network_name_reclaim
WHERE old_name = $1
`,
name,
)
server.WithPgResult(result, err, func() {
if result.Next() {
var coolDownUntil time.Time
server.Raise(result.Scan(&coolDownUntil))
if server.NowUtc().Before(coolDownUntil) {
available = false
}
}
})
})

if reasonErr != nil {
return false, fmt.Errorf("failed to check network name availability: %w", reasonErr)
}

return available, nil
}

// requireVerifiedIdentityBound checks that the user has at least one verified
// email/phone password auth, SSO auth, or wallet bound. Seedphrase-only users
// can't claim/change names — they need a verifiable identity method.
func requireVerifiedIdentityBound(ctx context.Context, userId server.Id) error {
var hasBoundAuth bool

server.Db(ctx, func(conn server.PgConn) {
result, err := conn.Query(
ctx,
`
SELECT EXISTS (
SELECT 1 FROM network_user_auth_password
WHERE user_id = $1 AND verified = true
UNION ALL
SELECT 1 FROM network_user_auth_sso
WHERE user_id = $1
UNION ALL
SELECT 1 FROM network_user_auth_wallet
WHERE user_id = $1
)
`,
userId,
)
server.WithPgResult(result, err, func() {
if result.Next() {
server.Raise(result.Scan(&hasBoundAuth))
}
})
})

if !hasBoundAuth {
return fmt.Errorf("You must verify an email, bind a social login, or connect a wallet before changing your network name.")
}

return nil
}
Loading