Skip to content

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

Open
Ryanmello07 wants to merge 51 commits into
urnetwork:mainfrom
Ryanmello07:feat/seedphrase-to-upstream
Open

feat: seedphrase account system + bulk-delete offline clients#406
Ryanmello07 wants to merge 51 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.
Ryanmello07 and others added 11 commits July 21, 2026 18:34
…at/seedphrase-to-upstream

Resolved 5 conflicting files (go.mod auto-merged clean on this branch,
unlike beta/self-contained-env, since this curated branch lacks the
CORS/extra-deps additions):
- go.sum: regenerated via go mod tidy.
- db_migrations.go: both sides only appended new migrations; kept both.
- model/network_model.go: took ours (empty) -- same UpgradeGuest
  deletion as beta/self-contained-env, though upstream's deleted-region
  boundary was larger here (this branch's own cleanup already removed
  the UpgradeGuest* types too, not just the function).
- model/device_association_model.go: identical conflict/resolution to
  beta/self-contained-env -- took upstream's `adopted = true` close,
  dropped our leftover isGuestMode/isPro lines (isPro already computed
  earlier post-rewrite; authType == AuthTypeGuest is unreachable now).
- model/peer_model_test.go (the hard one): two real conflicts, not one
  mechanical diff:
  - TestNetworkPeerRegistryFlushRecovery: adopted upstream's improved
    design (re-add a *different* peer during recovery to prove the
    resync carries fresh data, not stale accumulator state, plus an
    intermediate zero-count assertion), translated to this branch's
    assert.Equal/NotEqual style instead of connect.AssertEqual.
  - End of TestNetworkPeerTopLevelClientLimit: not a real conflict --
    upstream added two entirely new tests (TestNetworkTopLevelClient-
    LimitDisabled, TestNetworkProviderConnectionExemptFromLimit) right
    after a line we'd also touched (assert-style only). Added both,
    translated to assert.Equal/NotEqual.
  - Separately (found while resolving, confirmed pre-existing via a
    clean-worktree go vet on the unmodified branch, unrelated to this
    merge): every NewNetworkPeerListener call in this file was already
    missing its second argument (fullReadEvery) against the real,
    unchanged 2-arg signature -- a pre-existing compile break on this
    branch. Fixed all 10 call sites to match upstream's real signature.

Verified: go build . ./model/... ./controller/... ./cli/api and
go vet on the same, both fully clean -- including peer_model_test.go,
which was the one file genuinely at risk of a bad manual merge.
Add/remove auth method, generate/regenerate seedphrase, and change/claim
network name were previously unlimited, unlike account creation (which
already has a 5/day IP-based limit via network_create_rate_limit.go).

New model/account_action_rate_limit.go mirrors that pattern but keys by
userId instead of client_address_hash, since these are authenticated
account actions rather than pre-auth ones:
  - add_auth: 5/day, counted on success only
  - remove_auth: 5/day, counted on success only
  - generate_seedphrase / regenerate_seedphrase: 5/day each, counted on
    success only
  - change_network_name (shared by ChangeNetworkName and ClaimNetworkName):
    2/day, counted on every real attempt (after format/availability
    validation passes), matching the "strict" limit requested for this
    action

Success-only counting for add/remove/generate/regenerate avoids the kind
of self-lockout bug already found elsewhere in this port (rejected
attempts don't burn budget). Name change counts attempts rather than
successes since it's explicitly the strict one, but only after cheap
validation so malformed/unavailable-name requests don't cost budget.

One shared network_user_action_attempt table (user_id, action, create_time)
backs all five limits instead of five dedicated tables, since they share
the exact same shape and query pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
Covers: limit enforcement (allows up to N, blocks the N+1th), independent
counters per (user, action), unrecorded checks never burning budget
(the success-only semantics AddAuth/RemoveAuth/generate/regenerate rely
on), the atomic check-and-record path used by name change, window
expiry, and the maintenance cleanup function.

Run against a real local Postgres (server/local/run-local.sh); all 6
pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
…ame budget bundling

Two real bugs surfaced by an Opus review of the rate-limiting feature:

1. maxAccountActionAttemptsError() returned a "429 " prefix intended for
   RaiseHttpError's top-level-error convention, but every call site here
   embeds the message into a soft JSON Error field with a nil top-level
   error, so the literal string "429 ..." was shipping to clients verbatim.
   Removed the prefix and gave each action a human-readable name instead,
   so a client can tell which of the five different limits it hit.

2. ClaimNetworkName and ChangeNetworkName shared one 2/day counter. Claim
   is a first-time, once-per-account onboarding action, so it could
   consume half of a brand-new user's same-day rename budget before
   they've ever picked a real name -- a normal claim-then-rename-once flow
   would hit the limit on day one. Gave claim its own action constant and
   counter (still 2/day), selected via the existing reclaimCooldown flag
   that already distinguishes the two call paths.

Added tests: action constants are pairwise distinct and each produces a
specific (non-fallback) error message, and claim/change now have
independent counters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
Investigation (with upstream) found validateClientIdentityArgs had
regressed from upstream: it checked session.ByJwt.GuestMode to decide
whether a caller may assign explicit Roles/Principal to a new client via
/network/auth-client or /auth/code-create, but that check is only
reliable until a legacy guest account's first token refresh -- which
unconditionally re-signs with GuestMode=false regardless of real account
state (a separate, accepted-as-is behavior). After that, a bare guest
account (auth_type='guest', zero rows in any auth table) could assign
arbitrary roles/principal indistinguishably from a real account.

Per decision: guest signup is retired and silent upgrade-to-normal-user
treatment for legacy guests is acceptable, but this one check should
still reflect whether the account has actually gained a real auth
method, not the stale JWT claim.

Replaced the check with a new HasAnyAuthMethod(ctx, userId) that counts
rows across the four auth tables live. This blocks a still-bare guest
correctly, and self-heals the moment that guest adds any real method via
AddAuth -- no refresh or auth_type backfill required (AddAuth never
updates network_user.auth_type, so that column stays 'guest' forever
even after upgrade; only a live table check is accurate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
Two deployment-wide guards on RemoveNetworkClients (POST
/network/remove-clients), an admin-facing endpoint with no client-app
caller, per user decision: cap total blast radius regardless of who
triggers it, rather than per-account/network.

- A global hourly ceiling (MaxBulkClientRemovalsPerHour = 1,000,000,
  BulkClientRemovalWindow) on total clients admitted through the bulk
  API across all networks combined, tracked in a new
  bulk_client_removal_quota ledger table and checked+recorded
  atomically before a request is processed (sync or async). The
  single-client removal API is unaffected -- it has no comparable
  blast radius.
- A global concurrency cap (MaxConcurrentBulkClientRemovalRuns = 10) on
  how many networks may have a background bulk-delete run in flight or
  queued at once, using a new task.CountPendingByFunctionName helper
  against pending_task, so many networks can't all load the task
  queue/database at the same time.

Both are soft admission checks the caller can retry after (mirroring
AlreadyInProgress), not hard invariants -- consistent with this
codebase's existing account-action rate limiter, which accepts the
same small race window under RepeatableRead isolation.
countRecentAccountActionAttempts and CheckAccountActionRateLimit compared
`now() - INTERVAL ... <= create_time` in SQL, mixing a timestamptz
expression (now()) against a naive `timestamp` column (create_time,
holding UTC wall-clock values via server.NowUtc()). Postgres resolves
that comparison by casting through the session's TimeZone setting, so on
a non-UTC session the effective window silently shifts by the zone
offset.

Found while adding an hourly bulk-delete quota with the same pattern:
on this environment's session timezone (BST, UTC+1), a 1-hour window
was fully masked -- every check saw 0 recent attempts regardless of
what was actually recorded, because the ~1 hour skew was as large as
the window itself. This account-action limiter's 24h windows are wide
enough that the same skew goes unnoticed in practice, but the
underlying comparison was still wrong.

Fix: compute the cutoff in Go (server.NowUtc().Add(-window)) and bind
it as a plain parameter, removing the now()/naive-timestamp cast
entirely. Same pattern already used by RemoveExpired* cleanup
functions in this file.

Note: the identical now()-vs-naive-timestamp pattern also appears in
model/network_create_rate_limit.go, wallet_auth_challenge_attempt.go,
auth_model.go, and auth_model_attempt.go (all pre-existing, not part of
this session's work) -- worth a follow-up look, not fixed here.
Both independent Opus reviews of the abuse-limit commit caught the
same real bug: RemoveNetworkClients charged the global hourly quota
before checking the concurrency cap and attempting the run_once
schedule, so a request rejected by TooManyConcurrentRuns or
AlreadyInProgress still burned shared budget for zero removals. Since
AlreadyInProgress explicitly tells the caller to retry, a single
network's ordinary retries of one large request could exhaust the
whole deployment's hourly ceiling for everyone else within a couple of
attempts -- the opposite of what an abuse limit is for.

Fix: charge quota only once a request is truly admitted. The sync path
already always runs once reached, so charging immediately before it is
unchanged. The async path now checks the concurrency cap and attempts
the schedule first; only after ScheduleTaskIfAbsent actually inserts a
task row does it charge quota, and if the quota check then rejects, it
cancels the just-scheduled task via task.RemovePendingTask so the
run_once key frees up immediately rather than staying held by a
request that was ultimately rejected.

Added tests covering both fronts: the existing AlreadyInProgress and
TooManyConcurrentRuns tests now assert the ledger wasn't charged, and
a new test drives the quota-rejects-after-schedule path directly,
confirming the task is cancelled and the run_once key is free
afterward.
…g them

Per product decision: a request that can't fit the current hour's
deployment-wide budget should wait for a later hour rather than fail
outright, with a hard daily cap (24 x the hourly limit = 24,000,000)
as the only case that's actually shown to the caller as an error.

Replaces the rolling trailing-hour window with fixed, non-overlapping
hourly buckets (UTC) -- a rolling window has no discrete "next hour"
to wait for, so fixed buckets are what make queueing well-defined.
ReserveBulkClientRemovalSlot walks forward from the current hour,
placing a request in the earliest bucket with room; if none of the
next 24 hours has room, that's the daily cap, and the request is
rejected. A single request can never itself exceed a bucket's ceiling
(both are capped at 1,000,000), so daily-cap rejection only happens
under genuine contention from other reservations.

RemoveNetworkClients now determines *when* a request will run (now vs.
a future bucket) before deciding *how* (synchronous vs. background
task): only a request whose reserved bucket is the current hour AND
is small enough still runs synchronously, unchanged from before. Any
other admitted request -- large, or deferred to a future hour -- goes
through RemoveNetworkClientsTask with RunAt set to its bucket's start,
so even a handful of client ids can end up "scheduled for 3pm" if the
current hour is full. The response now carries scheduled_for so a
caller (or an operator) can tell "running now" from "queued".

Per product decision, a network's single-flight lock (AlreadyInProgress)
is held for the whole queued+running duration, same as today -- one
logical bulk-delete per network at a time, whether it's waiting its
turn or actually executing.

Since the bucket has to be known before scheduling (it becomes the
task's RunAt), reservation now happens before the run_once admission
check, the reverse of the previous ordering; if that check then fails
(AlreadyInProgress), the reservation is released via
CancelBulkClientRemovalReservation rather than left charged against a
request that never ran.

Also fixes a concurrency-cap self-deadlock this design would otherwise
hit: the cap counted every pending bulk-delete task regardless of
run_at, so a backlog of merely-queued (not yet running) requests would
fill the cap and block admission of brand new ones. Renamed to
CountAvailableByFunctionName and scoped to run_at <= now, so only
actually-running/runnable work counts against the concurrency limit.

Tests cover: filling a bucket then rolling into the next hour,
exhausting the full daily lookahead to trigger real rejection,
releasing a cancelled reservation, expiring old buckets without
touching ones still in the lookahead window, a small request queueing
when the current hour is full, and the reservation being released when
AlreadyInProgress fires after it was made.
…ency cap to async work only

Two independent Opus reviews of the queueing redesign converged on one
substantive finding: CountAvailableByFunctionName (which excludes
future-run_at tasks so a queue backlog doesn't self-deadlock admission)
also means the concurrency cap can't see how large that backlog is.
Every request deferred to the same future hour got RunAt set to
exactly that hour's boundary, so a sustained period of heavy
bulk-delete traffic could queue many networks' tasks against the same
hour and have them all become eligible at the identical instant --
defeating the concurrency cap's purpose the moment that hour arrives.

Fix: a deferred request's actual run time is now jittered randomly
across its target hour (RunAt = bucketStart + rand[0, bucket
duration)), spreading a backlog's execution instead of bursting it.
A request landing in the current hour is unaffected -- it still runs
essentially immediately.

Also addresses a related but separate finding: the concurrency cap was
being checked before the sync/async branch point, so a tiny request
that ends up running synchronously (current hour has room, size fits)
could be rejected by an unrelated backlog of large background runs,
even though it was never going to touch the task system. The cap now
only applies once a request is determined to need scheduling (large,
or deferred to a future hour), restoring the sync path's original
independence from it.

New tests: sync-path immunity to the concurrency cap even when it's
fully occupied, ScheduledFor is correctly set on the immediate (not
just deferred) async path, and the sharpest coverage gap both
reviewers flagged -- a network's first request genuinely deferred to a
future hour (not a directly-injected fake), then a second request for
the same network before that hour arrives, confirming the run_once
lock is held for the whole queued+running duration even though the
deferred task doesn't count against the concurrency cap while waiting.
The Opus reviews of the thundering-herd fix both noted the existing
tests would pass even if the jitter line were reverted (they only
check ScheduledFor falls in a range or differs from the current
bucket, both true with or without jitter). Verified this empirically
by disabling the jitter and confirming the new test fails without it,
then restoring it and confirming the full suite passes.

Drives 5 independently deferred requests (distinct networks, same
target hour) and asserts their ScheduledFor values aren't all
identical -- a real signal that jitter is spreading execution across
the hour, not just landing everything on the boundary.
@Ryanmello07
Ryanmello07 marked this pull request as ready for review July 22, 2026 23:13
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