Skip to content

feat(admin): add accountDetailsByNpub query - #495

Merged
islandbitcoin merged 9 commits into
mainfrom
feat/admin-account-by-npub
Aug 26, 2026
Merged

feat(admin): add accountDetailsByNpub query#495
islandbitcoin merged 9 commits into
mainfrom
feat/admin-account-by-npub

Conversation

@bobodread876

@bobodread876 bobodread876 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Why

The in-app Nostr support channel creates Chatwoot contacts keyed only by the sender's npub — support agents see npub1hy6r…8fq6 and four "Unavailable" fields. The backend already knows the npub→account mapping (every app user's npub is synced via userUpdateNpub), but nothing reachable exposes a reverse lookup.

What

  • accountDetailsByNpub(npub: npub!): AuditedAccount! on the admin GraphQL API, mirroring the existing accountDetailsBy{Username,UserPhone,Email,AccountId} queries.
  • App layer: Admin.getAccountByNpub delegating to AccountsRepository().findByNpub (exact $eq match; format validation happens at the GraphQL boundary via the existing npub scalar).
  • accountReleaseNpub admin mutation — the revocation path for a squatted key, with optional reassignment to the rightful owner.
  • Unique partial index on accounts.npub, plus the migration that lowercases and de-duplicates before building it.
  • SDL regenerated with yarn write-sdl.

Consumed by the ERP-side support lookup endpoint (frappe-flash-admin) which relays it to the nostr-dm-bridge so it can populate the Chatwoot contact card.

Rollout — migration must land before the pods

src/migrations/20260824120000-accounts-unique-npub.ts builds the first unique index on a field that holds duplicates in production, and schema.ts now declares that index on AccountSchema too. graphql-main-server.ts boots with setupMongoConnection(true), which runs syncIndexes() over every model: against un-deduped data createIndex rejects with E11000, setupMongoConnection rethrows (src/services/mongodb/index.ts:107), and the .catch in graphql-main-server.ts logs a single "server error" line and returns. bootstrap() and both Apollo servers never start, the process stays alive on mongoose's open handles, and the pod reports Ready with no listener.

The chart already enforces the ordering, and this PR relies on it: every galoy workload — api, websocket, trigger, exporter, the ibex/bridge/fygaro webhooks and the cronjobs — carries a wait-for-mongodb-migrate initContainer (groundnuty/k8s-wait-for job-wr) that blocks on the per-revision <release>-mongodb-migrate-<revision> Job and fails if that Job fails, so the app container cannot start ahead of the migration (charts/flash/templates/api-deployment.yaml, charts/flash/templates/galoy-migration-job.yaml).

Two ways to lose that guarantee, both producing the dead-pod failure above:

  1. Bumping galoy.images.app.digest without bumping galoy.images.mongodbMigrate.digest in the same release — the migrate Job then runs an older image that does not contain this migration, succeeds, and lets the new app through against un-migrated data. Bump both digests together.
  2. Force-rolling or recreating a Deployment out of band, so the initContainer's Job is not the one carrying this migration.

If a pod is up with no listener after this ships, check that the revision's migrate Job ran 20260824120000-accounts-unique-npub before touching anything else. The same note lives in the migration's own docstring.

Tests

  • yarn test:unit — 215 suites / 2304 tests green.
  • yarn test:integration — includes the new test/flash/integration/accounts/npub.spec.ts, which exercises the npub repository layer against a real mongo: claimNpub onto an account with no npub field, onto a legacy explicit npub: null, onto one already holding a key (refused, held key unchanged), two accounts claiming the same key (unique partial index → DuplicateKeyForPersistError), findByNpub, unsetNpub, and releaseNpub's reassignment end to end. It sits under integration/accounts/ rather than integration/services/ because the integration jest config ignores the services and wallet directories.
  • That spec caught a real defect on its first run: findByNpub was planning as a COLLSCAN. Dropping the case-insensitive collation was only half of it — mongo will not select an index whose partial filter is $type-shaped for a bare equality, so the query now restates $type: "string" alongside the $eq. The plan assertion is taken from the filter the repository actually sends (captured via mongoose's debug hook), so it cannot pass by restating the filter.
  • make check-codetsc-check-noimplicitany, tsc-check, eslint-check, build, check:sdl (no schema diff), check-yaml, madge-check all clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_015xSxhf5Kijfib4PbExGESk

Support tooling needs to resolve a Nostr pubkey to a Flash account so the
Chatwoot contact created by the nostr-dm-bridge can be enriched with the
real username/phone/email. The repository lookup (findByNpub) already
existed; this exposes it on the admin GraphQL API alongside the other
accountDetailsBy* queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xSxhf5Kijfib4PbExGESk
bobodread876 and others added 8 commits August 24, 2026 17:11
Review fixes for the accountDetailsByNpub query.

- accounts.npub gets a unique partial index plus a migration that lowercases
  existing values, keeps the oldest account in each duplicate group and unsets
  the rest, then builds the index. setNpub now refuses an npub already claimed
  by another account (NpubNotAvailableError) instead of silently overwriting,
  so the support-desk resolver can no longer surface one customer's phone,
  email and level under another's identity.
- Npub scalar: parseLiteral's success branch had no `return`, so graphql-js
  read it as failed coercion and rejected every inline-literal npub as
  malformed. parseValue now lowercases too, so both paths normalise identically.
- findByNpub drops the `.collation(...)` (bech32 is lowercase-only, and a
  non-simple collation cannot use the new index) and returns
  CouldNotFindAccountFromNpubError, so a miss no longer reports
  "Account does not exist for username npub1…".
- Admin.getAccountByNpub takes a branded Npub and validates via a new
  checkedToNpub rather than laundering a raw string with `as Npub`; it moves to
  its own module so it is unit-testable without importing the admin barrel.
- The query field is typed (GT.Field<null, GraphQLAdminContext, {...}>), so the
  `npub instanceof Error` guard is checked by the compiler.

Tests: the admin spec is now a schema-execution spec over the real field, real
scalar and real error map (variable form, inline-literal form, case
normalisation, malformed rejection, npub-worded 404, SDL registration), plus new
specs for getAccountByNpub, setNpub's duplicate refusal, findByNpub's query
shape and error class, and the schema index declaration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xSxhf5Kijfib4PbExGESk
Review fixes on the accounts.npub uniqueness work.

- Migration no longer picks a duplicate-group "winner" by created_at. The
  oldest account is often an abandoned one left behind by a phone reset,
  while the live handset holding the nostr key is newer — and picking wrong
  is unrecoverable in-product, since setNpub refuses an already-claimed npub
  and no admin mutation can release one. Every account in the group now has
  npub unset and logged; first re-link wins, and the unique index makes that
  race safe. The header comment stops promising a re-link path the code
  refuses and documents the literal mongo recovery command instead.

- The lowercase repair now targets the ids the audit scan already collected
  instead of `{ npub: { $type: "string" } }`, which rewrote every
  npub-bearing account — oplog churn and index re-touching during the deploy
  window for zero additional repairs.

- setNpub translates a lost concurrent-write race into NpubNotAvailableError.
  The unique index raises E11000, which parseRepositoryError turns into
  DuplicateKeyForPersistError, which error-map buckets into
  UnexpectedClientError — telling a user who lost a benign race that the
  backend broke, and logging it as unexpected.

- Adds test/flash/unit/migrations/accounts-unique-npub.spec.ts. Both
  destructive branches were unreachable in CI (`make test-migrate` runs
  against a clean database), so the first `$unset: { npub: "" }` would have
  been against real customer identities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xSxhf5Kijfib4PbExGESk
…okup

Two review findings on the npub work.

An npub claim is now permanently unique and still carries no proof of key
control. `userUpdateNpub` takes a bare npub from any authenticated account, so
anyone can read a victim's npub off a public relay and claim it on a throwaway
account first; `setNpub` then refuses the real owner forever, and the support
desk resolves the victim's DMs to the squatter's contact card. The admin surface
was read-only, so the only remedy was a hand-written `$unset` against prod
mongo. Adds `accountReleaseNpub` to the admin schema, backed by
`Accounts.releaseNpub` and a repository `unsetNpub` — `update` cannot clear the
field because mongoose strips undefined keys from an update doc. Releasing is
not reassigning: the key goes back to unclaimed and whoever holds the secret
re-links from the app. The migration header's manual-recovery recipe is replaced
by a pointer at the mutation.

`Accounts.findByNpub` is the twin of `Admin.getAccountByNpub` but never got the
same validation. This branch dropped the case-insensitive collation from the
repository query, which made normalisation mandatory — and this path had none,
so any non-GraphQL caller (script, backfill, REST shim) passing a mixed-case
npub got a silent not-found on a real user, surfacing as `isFlashNpub: false`.
It now runs `checkedToNpub` like its admin twin, and moved out of the barrel so
it can be unit tested against a mocked repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xSxhf5Kijfib4PbExGESk
Review follow-ups on accountReleaseNpub.

Attribution: the resolver dropped `ctx`, so nothing recorded who freed which
key. The admin server's Apollo context never assigns `req.gqlContext`, so its
Pino line logs the actor as undefined, and neither the account document nor the
payload retained the npub that was removed — a SystemManager, or anyone with a
stolen admin JWT, could release a victim's npub and re-claim it from a throwaway
via `userUpdateNpub` with no trace. The resolver now passes `ctx.user.id` down
as `releasedByUserId` (matching userUpdatePhone / accountUpdateStatus /
cashWalletCutoverRollback), the app layer reads the account first and emits a
structured `baseLogger.info({ accountId, previousNpub, releasedByUserId })`, and
`previousNpub` comes back on the payload.

Registration guard: the spec read the checked-in SDL off disk, which proves
nothing about wiring — `MutationType` spreads `unauthed` and `authed` into
identical SDL, so moving the field out of the shield-guarded bucket was
invisible to both that test and `check:sdl`. It now asserts against
`mutationFields.authed` / `mutationFields.unauthed` directly.

Reassignment: release-then-re-link pits a human against the squatter's script,
since `userUpdateNpub` needs no proof of key control. `reassignToAccountId`
hands the freed key straight to the rightful owner. This repository has no
MongoDB sessions anywhere, so the two writes are not atomic; the residual window
is documented rather than claimed away, the target is validated before anything
is freed, and the unique partial index is what guarantees the claim cannot
collide.

Silent no-op release: `$unset` on an account holding no npub still matches
`_id`, so `findOneAndUpdate` returned the document and the operator was told the
release succeeded — then sent the customer off to re-link, where `setNpub`
refuses them because the squatter still holds the key. The update now runs with
`new: false` and refuses with `NoNpubToReleaseError` when the pre-update
document carries no npub.

Operator-facing errors: both realistic mistakes surfaced as "contact support" or
a leaked class name. Adds `CouldNotFindAccountFromIdError` mapped to
`NotFoundError` and moves `InvalidAccountIdError` into the
`ValidationInternalError` bucket; the tests now assert the user-facing code and
message instead of pinning the internal class name.

Vacuous assertions: three `expect(...npub).toBeUndefined()` checks ran against
fixtures that never carried an npub. The mocks now hold one and the assertions
verify the code drops it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xSxhf5Kijfib4PbExGESk
…l-npub targets

The release and the reassignment are two writes with no transaction around
them. Everything below follows from that, plus the audit trail being the only
record the admin server keeps of who did what.

A claim that fails after the release landed no longer collapses to a bare
error. `releaseNpub` returns the populated `NpubRelease` carrying
`reassignmentError`, and `AccountReleaseNpubPayload` gained a matching field,
so `accountDetails` and `previousNpub` survive. Without them the operator was
told only "npub is already linked to another account", could not tell the key
had already left the holder, and found re-running the mutation refused with
`NoNpubToReleaseError`. `previousNpub` is what they feed to
`accountDetailsByNpub` to find the squatter and release it from there. The
failure is also logged at error level.

The release log line called the target `reassignedToAccountId` before the claim
had been attempted, so a lost claim left an audit line asserting a reassignment
that never happened. It is `reassignToAccountId` now — intent — and a second
line records the actual outcome once the claim resolves.

Every rejection path now logs `admin npub release refused` with the actor, the
id as given, and a reason. A stolen admin token enumerating account ids used to
leave one line for the id that happened to hold a key and nothing for the rest;
the admin server's pino-http line cannot fill the gap, as it carries neither
the actor nor the body.

The target's npub check used `!== undefined` while the repository used
`typeof !== "string"`. `AccountRecord.npub` is `Npub | null` and the migration
deliberately leaves pre-existing `npub: null` documents alone, so a legacy
account was rejected as already holding a key and could never receive a
reassignment. Both layers check `typeof` now.

The holder `findById` is gone. It was a second round-trip and the staler of the
two reads: `unsetNpub` already reads the pre-update document to decide whether
anything was freed, so it is the only reader that cannot disagree with the key
the `$unset` removed. It returns `{ account, previousNpub }`. Target validation
still runs before the release.

The migration's duplicate-release comment claimed no admin mutation can release
an npub, contradicting its own recovery section and this mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xSxhf5Kijfib4PbExGESk
…ookup

Two review findings on the npub release path.

`claimNpub` was an unguarded `$set` on the target account. `releaseNpub`
checks the target holds no npub, but that check is a read from before the
release round-trip: if the target linked a different key via
`userUpdateNpub` in that window, the reassignment silently overwrote the
just-claimed key, which became unclaimed with no log line saying so. The
unique partial index cannot catch it — it prevents duplicates, not
overwrites. The filter now re-checks at write time with
`npub: { $not: { $type: "string" } }` (not `$exists: false`, because legacy
documents hold an explicit `npub: null`, which is not a claim and must not
block a reassignment). A no-match is ambiguous between "no such account"
and "claimed a key since the caller checked", so one follow-up read
disambiguates; the concurrent-claim case surfaces as
`AccountAlreadyHasNpubError` in `reassignmentError`, alongside the existing
collision path.

`Admin.getAccountByNpub` was a line-for-line copy of `Accounts.findByNpub`
— same validation, same normalisation rationale, same repository call. Two
copies invite drift: the next fix would land in one and not the other. The
admin module is now a re-export of the accounts one. Its spec still imports
via the admin path, so coverage of the re-export's registration survives.

Tests: `claimNpub` asserts the guard is in the filter and that a
concurrently-claimed target is refused rather than overwritten;
`releaseNpub` asserts the refusal reaches the operator as
`reassignmentError` on an otherwise-landed release, with no `reassignedTo`.
Both fail against the pre-fix code.
Review fixes.

Say which failure it was. Three causes reach the single "npub released but
reassignment failed" line and each has a different recovery — the key is gone
(hunt the holder with accountDetailsByNpub), the key is unclaimed (retry
against another target), or the write failed (retry the same call). The line
carried none of that, and the admin server never assigns req.gqlContext, so
there is no request log to fall back on. It now logs `reason` off the raw
repository error, before the DuplicateKeyForPersistError -> NpubNotAvailable
mapping, plus the message. Covered by a table-driven spec that also asserts the
three reasons are distinct.

Exercise the repository layer against a real database. The guards this PR
added are index- and filter-shaped, and asserting a filter literal back at a
mocked mongoose model cannot report on whether mongo matches a missing field
and an explicit `npub: null` while rejecting a string. New integration spec
covers claimNpub onto no-field / legacy-null / already-held targets, the unique
partial index tripping on a second claimant, findByNpub, unsetNpub, and
releaseNpub's reassignment end to end. It lives under
test/flash/integration/accounts/ rather than .../integration/services/ because
the integration jest config ignores the services and wallet directories, and a
spec there would never run.

That spec immediately returned "no": findByNpub was planning as a COLLSCAN.
Dropping the case-insensitive collation was only half the problem — the index
is partial on `{ npub: { $type: "string" } }`, and mongo will not select a
partial index unless the query provably matches a subset of its filter, which
it cannot derive from an equality against a string literal. Restating the type
predicate in the query makes it an IXSCAN without changing the result set. The
plan assertion is taken from the filter the repository actually sends, captured
via mongoose's debug hook, so it cannot pass by restating the filter.

Document the rollout ordering on the migration. It builds the first unique
index on a field that holds duplicates in prod, and the schema now declares
that index too, so a pod that boots ahead of it hits E11000 in syncIndexes,
rethrows at src/services/mongodb/index.ts:107, logs one "server error" line and
never starts either Apollo server — up, ready, no listener. The chart already
gates this with the wait-for-mongodb-migrate initContainer on every galoy
workload; the docstring now names that guarantee and the two ways to lose it
(bumping the app image digest without the migrate image digest, and force-rolls
out of band).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…recoverable

Review residual on #495, and it is the gap the PR's own docblock warns
about. releaseNpub is two writes with no transaction. When the second
one fails, the key is unclaimed and the account it came off no longer
holds it — so accountReleaseNpub cannot be re-run against that account
(NoNpubToReleaseError), and the only other npub write in the codebase
is userUpdateNpub, which is self-service and subject to the very race
the reassignment exists to win. release-npub.ts:30-33 states it
plainly: telling the victim to go re-link pits a human against a
script. Support had nothing to run.

accountAssignNpub is that second write on its own. It adds no new
authority: the unique partial index still refuses a key another account
holds (NpubNotAvailableError), and claimNpub's write-time
`$not: { $type: "string" }` filter still refuses a target that already
holds one (AccountAlreadyHasNpubError) rather than overwriting it. It
can only ever fill a hole.

Takes the `npub` scalar rather than String, so it validates and
normalises at the boundary like accountDetailsByNpub — a mutation that
mints a permanent identity claim should not accept a looser input than
the query that reads one.

Also fixes the recovery guidance the previous round added, which was
wrong for two of the three causes it named: it told the operator to
"re-run against another target" or "retry the same call", both of which
re-enter unsetNpub and answer NoNpubToReleaseError. An operator
following that at 3am concludes the release never happened and stops,
while the key sits unclaimed for whatever script is polling
isFlashNpub. Every branch now names the same real remedy, and the
per-cause notes say whether assignment will succeed or the key must be
hunted down first.

8 new unit tests. SDL regenerated (check:sdl green), tsc + eslint +
build clean, all 90 npub tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
@islandbitcoin
islandbitcoin merged commit dcd912f into main Aug 26, 2026
15 checks passed
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