Skip to content

feat: seedphrase account system + bulk-delete offline clients#406

Draft
Ryanmello07 wants to merge 39 commits into
urnetwork:mainfrom
Ryanmello07:feat/seedphrase-to-upstream
Draft

feat: seedphrase account system + bulk-delete offline clients#406
Ryanmello07 wants to merge 39 commits into
urnetwork:mainfrom
Ryanmello07:feat/seedphrase-to-upstream

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

Summary

Two independent features, cherry-picked cleanly from a beta fork branch onto current main:

1. Seedphrase account system (replaces guest mode)

  • New users can sign up instantly with just a BIP39 24-word seedphrase (no email/wallet/SSO required) via POST /auth/network-create with no auth fields
  • Sign in with a seedphrase via POST /auth/login (seedphrase field)
  • Existing email/wallet/SSO users can generate/regenerate a seedphrase (POST /auth/generate-seedphrase, POST /auth/regenerate-seedphrase)
  • Add/remove auth methods on an account (POST /auth/add-auth, POST /auth/remove-auth) — cannot remove your last remaining method
  • Network name management: POST /account/change-name (24h cooldown on the old name) and POST /account/claim-name (no cooldown, for first-time custom names) — both require a verified email or an SSO login bound to the account (wallet-only auth does not qualify, since it can't be used to recover the account)
  • 5-seedphrase-signups-per-IP-per-day rate limit (network_create_attempt table), independent of the existing email/password auth-attempt rate limiter
  • Guest mode signup path (guest_mode on /auth/network-create, /auth/upgrade-guest, /auth/upgrade-guest-existing) is removed. Existing guest rows/JWTs are left untouched for backward compatibility (GuestMode stays on jwt.ByJwt, always false for new tokens; Network.GuestUpgradeNetworkId column/field kept).

2. Bulk delete for offline network clients

  • New batched deactivation path for large RemoveNetworkClients calls: small requests (≤10k ids) apply synchronously; larger requests are handed off to a background task (RemoveNetworkClientsTask) that processes up to 1M ids in bounded batches, so a single call can clear a network with hundreds of thousands of offline clients without a long-running request-path transaction
  • Request body capped at 100MB; input ids deduplicated before processing
  • One background bulk-delete run per network at a time (run_once key), with reschedule/continuation support for partial batches
  • New index (network_client_network_id_client_id) to support the batched deactivation query at scale

New tables

  • network_user_auth_seedphrase — SHA-256 lookup + Argon2id hash for seedphrase auth (never stores the raw mnemonic)
  • network_name_reclaim — 24h cooldown pool for changed network names
  • network_create_attempt — 5-per-IP-per-day seedphrase signup rate limit

New endpoints

Method Path Auth
POST /auth/network-create No (seedphrase path activates when no auth fields are sent)
POST /auth/login No (seedphrase field added alongside existing methods)
POST /auth/add-auth Required
POST /auth/remove-auth Required
POST /auth/generate-seedphrase Required
POST /auth/regenerate-seedphrase Required
POST /account/change-name Required (verified email/SSO)
POST /account/claim-name Required (verified email/SSO)

Removed endpoints

  • POST /auth/upgrade-guest
  • POST /auth/upgrade-guest-existing

Notes for reviewers

  • This PR was built by cherry-picking commits out of a larger fork/beta branch, then squash-fixing the fallout (a handful of migration-ordering conflicts, gofmt realignment, and stale test references to the removed guest-upgrade types/wrappers). Commit history is preserved as individual, mostly-focused commits rather than one big squash.
  • api/spec_conformance_test.go is updated to match the new/removed routes. The OpenAPI spec in the separate connect repo (connect/api/bringyour.yml) still lists the removed /auth/upgrade-guest* routes and doesn't yet list the new seedphrase/account routes — that update is out of scope here and should be coordinated in a follow-up PR against connect.
  • Deliberately excluded from this PR: beta self-contained-env Docker/Dockerfile changes, .superpowers/ planning docs, BETA.md/beta-setup scripts, and any CI/workflow changes — those are fork/beta-specific and not relevant upstream.

Verification

  • go build ./... passes
  • go vet ./api (spec conformance package) passes clean
  • Manually tested live against a self-contained beta deployment: seedphrase signup/login/regenerate, add/remove auth, rate limiting (5 allowed, 6th blocked with 429), name change/claim gated on verified email, account deletion cleanup

Ryanmello07 and others added 23 commits July 17, 2026 00:09
…tions

Adds three tables:
- network_user_auth_seedphrase: BIP39 seedphrase auth storage
- network_name_reclaim: 24h cooldown pool for changed network names
- network_create_attempt: 5-per-IP-per-day seedphrase signup rate limit
…rate limit

- seedphrase_controller: errors returned in result struct (not fmt.Errorf)
- auth_controller: RemoveAuth errors returned in result struct
- account_controller: accept both network_name and new_name, return name
- auth_model: seedphrase login errors returned in result, not Go error
- network_model: seedphrase path skips old auth rate limit, uses new one
- db_migrations: add network_create_attempt table
- network_create_rate_limit: new file with 5/ip/day limit for seedphrase
… name

Seedphrase-only users (or seedphrase + wallet only) can't call
claim-name or change-name until they bind an email, phone, or
social login. Returns a clear error message guiding them.
requireEmailOrSsoBound now checks verified=true on network_user_auth_password
instead of just existence. SSO auths (Apple/Google) are inherently verified
by the provider so they still count without a verified column.
Mirrors urnetwork#399 but redesigned for production scale: a
sync path for small requests (<=10,000 ids, applied inline) and an
async path for larger ones, handed off to a single background task
that self-chunks in bounded batches and reschedules its own
remainder rather than looping unboundedly or requiring the caller to
chunk requests themselves.

Also fixes:
- missing deactivate_time on the bulk path, which would have let the
  reap job's 30-day grace period be skipped for bulk-deleted clients
- an unbounded per-request size with no batching, which doesn't hold
  up against real per-network client counts

Adds task.ScheduleTaskInTxIfAbsent (task/task.go) for atomic
schedule-if-not-already-pending semantics, since the existing RunOnce
merge-on-conflict path only updates timing/priority on an existing
pending row, not its args -- reusing it naively for duplicate-request
rejection would silently drop a racing duplicate's client_ids while
still reporting success.
…t gap

Two independent review passes (DeepSeek, Gemini 3.1 Pro) confirmed no
critical issues; this addresses their medium/low findings:

- guard against a nil session.ByJwt in RemoveNetworkClientsTask rather
  than panicking on a nil dereference inside a MaintenanceTx
- clarify why RemoveNetworkClientsTaskPost's reschedule uses plain
  ScheduleTaskInTx rather than the IfAbsent variant (no possible
  conflict: the finishing task's own row is deleted in the same tx)
- lower MaxRemoveNetworkClientsCount 5M -> 2M: still 2x headroom over
  known ~1M real-world usage, materially smaller worst-case args_json
  payload on the request path
- assert deactivate_time is stamped in the real-worker lifecycle test,
  the only test exercising multiple task invocations
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…ling

- nil session.ByJwt in RemoveNetworkClientsTask now has coverage (the
  guard added in the prior commit had none)
- boundary tests at exactly RemoveNetworkClientsBatchCount (still
  sync) and exactly MaxRemoveNetworkClientsCount (still accepted) --
  previously only the "+1 over" side of each boundary was tested
- a fast, isolated test of RemoveNetworkClientsTaskPost's reschedule
  logic (same run_once key, different network unaffected), as a
  complement to the slow 205k-row real-worker lifecycle test
…d, index, reduced cap, guest rejection

- Fix #1 (DoS): Add http.MaxBytesReader(100MB) to body read + reject guest-mode callers
- Fix #3 (abuse): Switch to WrapWithInputRequireAuthNoGuest
- Fix #4 (perf): Add network_client_network_id_client_id index migration
- Fix urnetwork#5 (correctness): restored original panic-based error propagation (by convention)
- Fix urnetwork#6 (quality): Deduplicate client IDs in RemoveNetworkClients input
- Fix urnetwork#7 (scale): Lower MaxRemoveNetworkClientsCount from 2M to 1M
- Copilot suggestion 1: Return zero server.Id{} when ScheduleTaskInTxIfAbsent is not scheduled
- Copilot suggestion 2: Add nil-ByJwt guard to RemoveNetworkClientsTaskPost
router_test.go still referenced WrapRequireAuthNoGuest/WrapWithInputRequireAuthNoGuest
and guest-mode JWTs, which no longer exist after guest mode removal. Strip the
guest-specific test routes and assertions.

network_client_handlers.go's RemoveNetworkClients also called the removed
WrapWithInputRequireAuthNoGuest wrapper; switch to WrapWithInputRequireAuth.
Removes stale references to model.UpgradeGuestArgs/UpgradeGuestResult and
model.UpgradeGuestExistingArgs (types removed with guest mode). Adds
registry entries for the new seedphrase and account-management endpoints:
/auth/add-auth, /auth/remove-auth, /auth/regenerate-seedphrase,
/auth/generate-seedphrase, /account/change-name, /account/claim-name.

Note: the connect/api/bringyour.yml OpenAPI spec (separate repo) still
lists the removed /auth/upgrade-guest* routes and doesn't yet list the
new seedphrase/account routes. That spec update is out of scope for this
PR and should be coordinated separately in the connect repo.
These result/error types (UpgradeGuestNetwork, UpgradeGuestResultVerification,
UpgradeGuestError, UpgradeGuestExisting*) had no remaining callers after the
UpgradeGuest/UpgradeFromGuestExisting functions were removed. Keeping
Network.GuestUpgradeNetworkId and Testing_CreateGuestNetwork, which still
reflect real DB state for existing (un-migrated) guest accounts.
AddAuth() was swallowing errors from addUserAuth(), addSsoAuth(), and
addWalletAuth(). If a user tried to add a second email (same auth_type),
SSO provider, or wallet, the function silently returned {} with no
error message despite the DB correctly rejecting the duplicate via PK
constraint. Now each path checks the returned error and surfaces it
as a proper JSON error response.
@Ryanmello07

Copy link
Copy Markdown
Contributor Author

🐛 Fix: Error propagation in AddAuth (cherry-picked 2025-07-17)

When a user tried to add a second email (same auth_type), SSO provider, or wallet to an account, the DB-level PK constraint correctly rejected the duplicate — but the error was silently swallowed and the API returned {} with no error message. Fixed by capturing errors from addUserAuth(), addSsoAuth(), and addWalletAuth() inside AddAuth() and returning them as proper JSON error responses. This applies to all auth types: email, phone, Google, Apple, wallet.

RemoveAuth('solana') only deleted the network_user_auth_wallet row, but the
network_user.wallet_address and wallet_blockchain columns were left set,
preventing that wallet from creating a new account. Now they are cleared so
the wallet can be reused for a fresh signup.
@Ryanmello07

Copy link
Copy Markdown
Contributor Author

🐛 Fix: Wallet can be reused after being removed

RemoveAuth('solana') only deleted the network_user_auth_wallet row but left network_user.wallet_address and network_user.wallet_blockchain non-null. This prevented the same wallet from being used to create a fresh account later. Now clears those columns as well. Wallet login after removal correctly returns the wallet_auth prompt instead of a JWT.

RemoveAuth only deleted auth rows but never updated the denormalized
auth_type column on network_user. After removing wallet auth, the
app still showed 'sign in with wallet' because auth_type remained
'solana'. Now every RemoveAuth path re-derives auth_type from whichever
auth tables still have rows, in priority order:
password > apple > google > solana > seedphrase.
NetworkUser struct now includes:
- SeedphraseAuths: list of linked seedphrase auths (mirrors WalletAuths)
- AuthTypes: flat string array of all linked auth methods
  e.g. ['solana', 'seedphrase'] or ['solana', 'seedphrase', 'email']
  The app can use this directly instead of guessing from auth_type.
…pe panic

countAuthMethods previously ran outside the deletion transaction, so two
concurrent RemoveAuth calls for different auth types could both read the
same pre-deletion count, both pass the "keep at least one method" guard,
and both commit against different rows in different auth tables (no
conflict under RepeatableRead) -- leaving the account with zero auth
methods and no recovery path short of DB intervention.

Move the count query inside the deletion tx behind a per-user
`FOR UPDATE` row lock so a concurrent call blocks and retries (via
server.Tx's existing serialization-failure retry) against the reduced
count instead.

Also replace the panic on an unrecognized auth_type with a returned
error, matching the existing "last auth method" validation pattern --
user-controlled input reaching the default case previously crashed the
request with a 500 and a stack-trace log entry instead of a clean error.
AuthLogin pre-inserts a success=false user_auth_attempt row and relies
on each login branch to flip it to success afterward. The password and
SSO branches did this; the seedphrase and wallet branches did not.

Since seedphrase/wallet logins have a nil userAuth, UserAuthAttempt's
rate limiter counts their failures per client IP only (5 in 5 minutes).
With success never recorded, 5 successful seedphrase or wallet logins
from one IP -- trivial on any shared NAT (household, office, mobile
carrier CGNAT) -- exhausts that budget and locks out all subsequent
auth from that IP, including unrelated users' SSO/password logins, for
the rate-limit window.

Seedphrase is now the primary login path (guest mode was removed), so
this was reachable in ordinary use, not just adversarially.
jwt.NewByJwt panics on a zero-value networkId. If a seedphrase auth row
ever outlives its network_user/network (this branch's RemoveNetwork
already deletes it, but the defensive gap remains for any other path
that could orphan the row), a subsequent login would pass the
hash/salt check and then panic on the network join instead of failing
cleanly, turning an edge case into a 500 on a public endpoint.
RemoveNetwork's batch delete covers network_user, network_user_auth_wallet,
network_user_auth_password, and network_user_auth_sso, but not
network_user_auth_seedphrase -- so deleting a network left the seedphrase
auth row (hash, salt, lookup) behind. A later login attempt with that
seedphrase would pass the argon2 check, then find no matching network_user
on the join and panic on a zero-value networkId in jwt.NewByJwt (guarded
separately in LoginWithSeedphrase). Also permanently blocked reusing that
seedphrase for a new account since the lookup column is unique.

(This fix already existed on the fork's feat/seedphrase-account-system
branch -- ported by hand here since a mechanical cherry-pick would have
been a no-op against this branch's history.)
…tests

58 assert.Equal/assert.NotEqual call sites had no import for
github.com/go-playground/assert/v2, so the whole model test package
failed to compile.

Once fixed, TestRemoveNetworkClientsRejectsOversizedRequest and
TestRemoveNetworkClientsExactlyAtCapIsAccepted would still fail: both
seeded their client-id slice with make([]server.Id, N), which is all
zero-value UUIDs. RemoveNetworkClients deduplicates ids before checking
the length cap, so N identical zero ids collapse to length 1 and never
exercise the boundary being tested. Seed with distinct server.NewId()
values instead, matching the existing correct idiom in
TestRemoveNetworkClientsUUIDArrayBinding.
The second half of TestNetworkUser used bare assert.Equal/assert.NotEqual
with no import (undefined: assert) -- this file otherwise uses
connect.AssertEqual throughout.

It also still asserted guest/seedphrase-shaped values (UserAuth == nil,
Verified == false, AuthType == AuthTypeSeedphrase) against a user created
by Testing_CreateNetwork, which unconditionally creates a password user
(UserAuth set, Verified: true, AuthType: AuthTypePassword). The block was
mechanically migrated from a guest-network helper without updating its
assertions, so it could never pass.

No seedphrase-creating test helper exists to restore the original intent,
so this switches to asserting what Testing_CreateNetwork actually
produces, matching the pattern already used earlier in the same test.
The cherry-picked fix used connect.AssertEqual, matching the fork
branch's convention where this file already imported "connect". This
branch's copy of the file was already migrated to go-playground/assert/v2
throughout and never imports "connect" -- adjusting to match.
networkCreateSeedphrase's only caller (NetworkCreate) guards entry on
networkCreate.AuthJwt == nil, so the "if AuthJwt provided, bind SSO too"
branch inside it could never execute -- dead code that also silently
swallowed a bind error via glog.Infof had it ever run, which would have
been a latent trap if the outer guard were ever loosened later.
changeNetworkName's reclaim-cooldown INSERT fired whenever an old name
existed, with no check that the new name actually differs from it. A
no-op rename (idempotent retry, resend, a flaky client re-POST with the
same name) would put the user's own current name into
network_name_reclaim, and the cooldown lookup has no owner exemption --
so the user would then be locked out of "renaming" back to the name
they still hold for 24h. Guard the insert on the name actually changing.
Same class of pre-existing test-compile break found and fixed on the
fork's feat/seedphrase-account-system branch, applied to the subset that
also exists here (this branch's network_model_test.go, router_test.go,
and spec_conformance_test.go were already clean of guest-mode test
scaffolding, so only these two needed the fix):

- controller/network_controller_test.go: three NetworkCreateArgs struct
  literals still set a GuestMode field that no longer exists on that
  type (model/peer_model_test.go:403 also sets GuestMode, but on
  jwt.ByJwt, which still has that field -- not a compile error, left
  as-is).
- task/task_test.go: a block of assert.Equal calls had no import for
  github.com/go-playground/assert/v2.
…Auth

The mirroring UPDATE added to sync network_user.wallet_address used
RaisePgResult, which panics on any error -- unlike the sibling INSERT a
few lines above, which returns a clean error. network_user.wallet_address
has a global (not per-user) unique index, so this UPDATE can legitimately
conflict: a legacy account whose network_user.wallet_address was set
before network_user_auth_wallet existed, and never cleared, collides
when a different user links that same address.

Since constraint violations are classified as transient, server.Tx would
retry the whole transaction for up to a minute against the same
permanent conflict before finally re-panicking, surfacing as an opaque
500 instead of the structured AddAuthMethodResult{Error:...} every other
AddAuth failure path returns. Catch it the same way the INSERT does.
A signed wallet challenge proves control of that identity the same
way a verified email or SSO login does, so it should count toward
requireVerifiedIdentityBound (formerly requireEmailOrSsoBound) just
like the other two methods.
…d hung instead of erroring

RefreshToken re-signed a new JWT using session.ByJwt.NetworkName --
whatever name was baked into the JWT being refreshed -- instead of
re-reading the current name from the DB. A rename via
change-name/claim-name would never be picked up until the client
happened to get a truly fresh JWT some other way (e.g. logging back
in).

addWalletAuth attempted the INSERT directly and let a duplicate-wallet
unique-constraint violation abort the transaction, then returned the
error cleanly (no panic). But server.Tx's default retry options retry
any failed COMMIT for up to 60s regardless of whether the underlying
cause is retryable -- committing an aborted transaction always fails
the same way on every retry, so this stalled every "wallet already
taken" request for up to a minute before ever surfacing an error.
Added a pre-check SELECT before the INSERT, mirroring the
validateUserAuthAvailability pattern addUserAuthInTx already uses for
email/phone auth, so the common case fails fast with a clean error
instead of ever touching the DB constraint.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants