Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions src/app/accounts/assign-npub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { checkedToAccountId } from "@domain/accounts"
import { DuplicateKeyForPersistError } from "@domain/errors"
import { checkedToNpub, NpubNotAvailableError } from "@domain/nostr"
import { baseLogger } from "@services/logger"
import { AccountsRepository } from "@services/mongoose"

/**
* The other half of `releaseNpub`'s reassignment, reachable on its own.
*
* `releaseNpub` is two writes and no transaction: the unset lands, then the
* claim. When the claim fails — mongo hiccups, or the target linked a key in
* the window — the key is unclaimed and the account it was taken off no longer
* holds it, so `accountReleaseNpub` cannot be re-run against that account: it
* returns `NoNpubToReleaseError`. Before this existed the only remaining npub
* write was `userUpdateNpub`, which is self-service and subject to the very
* race the reassignment exists to win — the squatter is the party polling
* `isFlashNpub`, so the desk's only advice was to pit a human against a script.
* This makes the second write retryable by an admin.
*
* Safety comes from the same two mechanisms the reassignment already relies on,
* not from new ones: the unique partial index refuses a key another account
* holds (surfaced as `NpubNotAvailableError`), and `claimNpub`'s write-time
* `$not: { $type: "string" }` filter refuses a target that already holds a key
* (`AccountAlreadyHasNpubError`) rather than silently overwriting it. So this
* can only ever fill a hole; it cannot take a key from anyone.
*
* `assignedByUserId` is the attribution trail, for the reason spelled out in
* `release-npub.ts`: the admin server never assigns `req.gqlContext`, so these
* structured lines are the only record of who moved a key. Refusals are logged
* too — a stolen admin token sweeping ids should not be able to probe silently.
*/
export const assignNpub = async ({
id,
npub,
assignedByUserId,
}: {
id: string
npub: string
assignedByUserId: UserId
}): Promise<Account | ApplicationError> => {
const idChecked = checkedToAccountId(id)
if (idChecked instanceof Error) return idChecked

const npubChecked = checkedToNpub(npub)
if (npubChecked instanceof Error) return npubChecked

const accountsRepo = AccountsRepository()

const claimed = await accountsRepo.claimNpub(idChecked, npubChecked)
if (claimed instanceof Error) {
baseLogger.error(
{
accountId: idChecked,
npub: npubChecked,
assignedByUserId,
reason: claimed.name,
error: claimed.message,
},
"admin npub assignment refused",
)
// Same mapping the reassignment path uses, so one cause reads identically
// wherever it surfaces: the key belongs to someone else.
if (claimed instanceof DuplicateKeyForPersistError) {
return new NpubNotAvailableError(npubChecked)
}
return claimed
}

baseLogger.info(
{ accountId: idChecked, npub: npubChecked, assignedByUserId },
"admin assigned an npub claim",
)

return claimed
}
21 changes: 21 additions & 0 deletions src/app/accounts/find-by-npub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { checkedToNpub } from "@domain/nostr"
import { AccountsRepository } from "@services/mongoose"

/**
* Also re-exported as `Admin.getAccountByNpub` — one implementation, two
* barrels, so fixes cannot drift between them. Normalised because the
* repository query is a plain `$eq` with no collation, so a caller that is not
* the GraphQL boundary (a script, a backfill, a REST shim) passing a mixed-case
* npub gets a silent not-found on a real user — which the public
* `isFlashNpub` query reports as `isFlashNpub: false`.
*
* Lives outside `index.ts` so it can be unit tested against a mocked
* repository — importing the accounts barrel drags in the notification stack.
*/
export const findByNpub = async (npub: Npub): Promise<Account | ApplicationError> => {
const npubChecked = checkedToNpub(npub)
if (npubChecked instanceof Error) return npubChecked

const accounts = AccountsRepository()
return accounts.findByNpub(npubChecked)
}
7 changes: 3 additions & 4 deletions src/app/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export * from "./enable-notification-category"
export * from "./enable-notification-channel"
export * from "./disable-notification-channel"
export * from "./set-npub"
export * from "./release-npub"
export * from "./assign-npub"
export * from "./find-by-npub"
export * from "./update-external-wallet"

const accounts = AccountsRepository()
Expand All @@ -43,10 +46,6 @@ export const getAccount = async (
return accounts.findById(accountId)
}

export const findByNpub = async (npub: Npub): Promise<Account | RepositoryError> => {
return accounts.findByNpub(npub)
}

export const getAccountFromUserId = async (
kratosUserId: UserId,
): Promise<Account | RepositoryError> => {
Expand Down
199 changes: 199 additions & 0 deletions src/app/accounts/release-npub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { checkedToAccountId } from "@domain/accounts"
import {
CouldNotFindAccountFromIdError,
CouldNotFindError,
DuplicateKeyForPersistError,
} from "@domain/errors"
import { AccountAlreadyHasNpubError, NpubNotAvailableError } from "@domain/nostr"
import { baseLogger } from "@services/logger"
import { AccountsRepository } from "@services/mongoose"

export type NpubRelease = {
account: Account
previousNpub: Npub
reassignedTo?: Account
// Set when the release landed and the claim meant to follow it did not. This
// is not an alternative to the release: the key is off the holder either way,
// and re-running the mutation now fails with `NoNpubToReleaseError`.
reassignmentError?: ApplicationError
}

/**
* The escape hatch for `setNpub`'s refusal. `userUpdateNpub` takes a bare npub
* from any authenticated account with no proof of key control, so anyone can
* read a victim's npub off a public relay and claim it first. The unique index
* then makes that permanent: the rightful owner gets `NpubNotAvailableError`
* forever, and the support desk resolves their DMs to the squatter's contact
* card. Support needs to be able to free the key from the admin panel rather
* than hand-writing an `$unset` against prod mongo.
*
* A bare release loses the race it exists to win: the squatter is the party
* polling `isFlashNpub`, so telling the victim to go re-link pits a human
* against a script. `reassignToAccountId` closes that by handing the key
* straight to the rightful owner. This repository has no MongoDB sessions
* anywhere, so the two writes are not a transaction: the key is unclaimed for
* the round-trip between them, and a claim that lands in that window makes the
* reassignment fail with `NpubNotAvailableError`. The release still stands, so
* that failure comes back as `reassignmentError` on an otherwise populated
* `NpubRelease` rather than as a bare error — a bare error reads as "nothing
* happened", and the operator would neither know the key is now unclaimed nor
* that recovering it means finding its current holder with
* `accountDetailsByNpub` and releasing it from there. The target is read and
* checked before the release so that everything knowable up front fails before
* the key is freed; the unique partial index guarantees the reassignment
* cannot collide with a concurrent claim of the same key, and `claimNpub`'s
* own write-time filter refuses a target that linked a different key after the
* pre-release check — surfaced as `AccountAlreadyHasNpubError` in
* `reassignmentError`, never a silent overwrite of the target's key.
*
* `releasedByUserId` is the whole attribution trail. Neither the account
* document nor the payload retains the npub that was removed, and the admin
* server never assigns `req.gqlContext`, so the Pino request log records the
* actor as undefined — the structured log lines below are the only record that
* a given admin took a given key off a given account. Refusals are logged for
* the same reason: a stolen admin token sweeping account ids leaves one line
* for the release that worked and, without them, nothing at all for the probes
* that did not.
*/
export const releaseNpub = async ({
id,
releasedByUserId,
reassignToAccountId,
}: {
id: string
releasedByUserId: UserId
reassignToAccountId?: string
}): Promise<NpubRelease | ApplicationError> => {
const accountsRepo = AccountsRepository()

const refuse = <E extends ApplicationError>(reason: string, error: E): E => {
baseLogger.warn(
{ accountId: id, releasedByUserId, reassignToAccountId, reason },
"admin npub release refused",
)
return error
}

const idChecked = checkedToAccountId(id)
if (idChecked instanceof Error) return refuse("malformed account id", idChecked)

const targetIdChecked =
reassignToAccountId === undefined
? undefined
: checkedToAccountId(reassignToAccountId)
if (targetIdChecked instanceof Error) {
return refuse("malformed reassignment target id", targetIdChecked)
}

let target: Account | undefined
if (targetIdChecked !== undefined) {
const found = await accountsRepo.findById(targetIdChecked)
if (found instanceof CouldNotFindError) {
return refuse(
"unknown reassignment target",
new CouldNotFindAccountFromIdError(targetIdChecked),
)
}
if (found instanceof Error) return found
// Also catches `reassignToAccountId === id`, where the target is the holder
// and there is nothing to move.
//
// `typeof` rather than `!== undefined`: the field is `Npub | null` on the
// record and the migration deliberately leaves pre-existing `npub: null`
// documents alone, so an account that has never linked a key can arrive
// here holding an explicit null. That is not a claim, and treating it as
// one would make such an account permanently ineligible to receive one.
if (typeof found.npub === "string") {
return refuse(
"reassignment target already holds an npub",
new AccountAlreadyHasNpubError(targetIdChecked),
)
}
target = found
}

// The holder's existence and its npub are both established by `unsetNpub`
// off the pre-update document, so this branch carries the refusals that a
// separate holder read used to make here — plus genuine write failures.
const released = await accountsRepo.unsetNpub(idChecked)
if (released instanceof Error) return refuse(released.name, released)

const { account, previousNpub } = released

baseLogger.info(
{
accountId: idChecked,
previousNpub,
releasedByUserId,
// Intent, not outcome. The claim has not been attempted yet and can still
// lose to a concurrent one; an investigator reading this line must not
// conclude the key reached the target.
reassignToAccountId: targetIdChecked,
},
"admin released an npub claim",
)

if (target === undefined) return { account, previousNpub }

const reassigned = await accountsRepo.claimNpub(target.id, previousNpub)
if (reassigned instanceof Error) {
baseLogger.error(
{
accountId: idChecked,
previousNpub,
reassignToAccountId: targetIdChecked,
releasedByUserId,
// Which failure, not just that one happened. Three distinct causes
// reach this branch and each has a different recovery, so a line that
// does not name the cause hands the operator all three procedures at
// once.
//
// What is true for ALL of them: the release already landed, so this
// account no longer holds the key and `accountReleaseNpub` cannot be
// re-run against it — it answers `NoNpubToReleaseError`. Recovery is
// `accountAssignNpub(accountId, npub)`, which is the second write on
// its own and carries the same two guards (unique index, write-time
// refusal of a target that already holds a key).
//
// `DuplicateKeyForPersistError` (surfaced to the caller as
// `NpubNotAvailableError`): someone else claimed the key in the window
// between the two writes — it is gone, so assignment will refuse too;
// find the new holder with `accountDetailsByNpub` and release it from
// there first.
// `AccountAlreadyHasNpubError`: the target linked a different key
// after the pre-release check — the released key is still unclaimed,
// so assign it to the right account before anyone polling
// `isFlashNpub` takes it.
// Anything else (e.g. `UnknownRepositoryError`): the write failed and
// the key is unclaimed — assign it to the intended target.
//
// Taken off the raw repository error, before the
// `DuplicateKeyForPersistError` → `NpubNotAvailableError` mapping
// below, so the line names the failure that actually happened.
reason: reassigned.name,
reassignmentError: reassigned.message,
},
"npub released but reassignment failed",
)
return {
account,
previousNpub,
reassignmentError:
reassigned instanceof DuplicateKeyForPersistError
? new NpubNotAvailableError(previousNpub)
: reassigned,
}
}

baseLogger.info(
{
accountId: idChecked,
previousNpub,
releasedByUserId,
reassignedToAccountId: target.id,
},
"admin reassigned a released npub",
)

return { account, previousNpub, reassignedTo: reassigned }
}
38 changes: 31 additions & 7 deletions src/app/accounts/set-npub.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { checkedToNpub, NpubNotAvailableError } from "@domain/nostr"
import { CouldNotFindError, DuplicateKeyForPersistError } from "@domain/errors"
import { AccountsRepository } from "@services/mongoose"
import { checkValidNpub } from "@domain/nostr"

import { ValidationError } from "ajv"

export const setNpub = async ({
id,
Expand All @@ -11,10 +10,35 @@ export const setNpub = async ({
npub: Npub
}): Promise<Account | ApplicationError> => {
const accountsRepo = AccountsRepository()
if (!checkValidNpub(npub))
throw new ValidationError([{ message: "Invalid npub format" }])

const npubChecked = checkedToNpub(npub)
if (npubChecked instanceof Error) return npubChecked

// An npub is an identity, and since this PR it is also a support-desk
// lookup key: `findByNpub` is a `findOne`, so two accounts claiming the same
// npub would resolve nondeterministically and could paint one customer's
// phone/email/level onto another's contact card. Refuse the claim here, and
// let the unique index on `accounts.npub` catch the concurrent-write race.
const existing = await accountsRepo.findByNpub(npubChecked)
if (!(existing instanceof Error)) {
if (existing.id !== id) return new NpubNotAvailableError(npubChecked)
return existing
}
if (!(existing instanceof CouldNotFindError)) return existing

const account = await accountsRepo.findById(id)
if (account instanceof Error) return account
account.npub = npub
return accountsRepo.update(account)
account.npub = npubChecked

const updated = await accountsRepo.update(account)
// The probe above and this write are not atomic, so two concurrent claims on
// the same npub can both pass it. The loser's write trips the unique index,
// which `parseRepositoryError` surfaces as `DuplicateKeyForPersistError` —
// an error `error-map` buckets into `UnexpectedClientError` ("please contact
// support"). It is not unexpected: it is the same refusal as the probe, and
// the caller deserves the same answer.
if (updated instanceof DuplicateKeyForPersistError) {
return new NpubNotAvailableError(npubChecked)
}
return updated
}
8 changes: 8 additions & 0 deletions src/app/admin/get-account-by-npub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* `Admin.getAccountByNpub` is `Accounts.findByNpub` under the admin barrel's
* name — same validation, same normalisation rationale, same repository call.
* A re-export rather than a copy so a fix (e.g. a bech32 validation upgrade)
* cannot land in one and not the other. The admin unit spec stays pointed at
* this module so registration coverage of the re-export survives.
*/
export { findByNpub as getAccountByNpub } from "@app/accounts/find-by-npub"
1 change: 1 addition & 0 deletions src/app/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./update-user-phone"
export * from "./send-cashout-notification"
export * from "./send-user-notification"
export * from "./invite"
export * from "./get-account-by-npub"

// Re-export query functions from invite module for admin GraphQL compatibility
export { getInviteById, listInvites } from "../invite/queries"
Expand Down
Loading
Loading