diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 9c8d30139..075dcbf6d 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -961,6 +961,20 @@ type Country scalar CountryCode @join__type(graph: PUBLIC) +input CreateInviteInput + @join__type(graph: PUBLIC) +{ + contact: String! + method: InviteMethod! +} + +type CreateInvitePayload + @join__type(graph: PUBLIC) +{ + errors: [String!]! + invite: Invite +} + type Currency @join__type(graph: PUBLIC) { @@ -1216,6 +1230,44 @@ input IntraLedgerUsdPaymentSendInput walletId: WalletId! } +type Invite + @join__type(graph: PUBLIC) +{ + contact: String! + createdAt: String! + expiresAt: String! + id: ID! + method: InviteMethod! + status: InviteStatus! +} + +enum InviteMethod + @join__type(graph: PUBLIC) +{ + EMAIL @join__enumValue(graph: PUBLIC) + SMS @join__enumValue(graph: PUBLIC) + WHATSAPP @join__enumValue(graph: PUBLIC) +} + +type InvitePreview + @join__type(graph: PUBLIC) +{ + contact: String! + expiresAt: String! + inviterUsername: String + isValid: Boolean! + method: String! +} + +enum InviteStatus + @join__type(graph: PUBLIC) +{ + ACCEPTED @join__enumValue(graph: PUBLIC) + EXPIRED @join__enumValue(graph: PUBLIC) + PENDING @join__enumValue(graph: PUBLIC) + SENT @join__enumValue(graph: PUBLIC) +} + enum InvoicePaymentStatus @join__type(graph: PUBLIC) { @@ -1633,6 +1685,7 @@ type Mutation callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload! captchaCreateChallenge: CaptchaCreateChallengePayload! captchaRequestAuthCode(input: CaptchaRequestAuthCodeInput!): SuccessPayload! + createInvite(input: CreateInviteInput!): CreateInvitePayload! deviceNotificationTokenCreate(input: DeviceNotificationTokenCreateInput!): SuccessPayload! feedbackSubmit(input: FeedbackSubmitInput!): SuccessPayload! idDocumentUploadUrlGenerate(input: IdDocumentUploadUrlGenerateInput!): IdDocumentUploadUrlPayload! @@ -1741,6 +1794,7 @@ type Mutation onChainUsdPaymentSend(input: OnChainUsdPaymentSendInput!): PaymentSendPayload! onChainUsdPaymentSendAsBtcDenominated(input: OnChainUsdPaymentSendAsBtcDenominatedInput!): PaymentSendPayload! quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! + redeemInvite(input: RedeemInviteInput!): RedeemInvitePayload! """ Returns an offer from Flash for a user to withdraw from their USD wallet (denominated in cents). @@ -2106,6 +2160,7 @@ type Query cashWalletCutover: CashWalletCutover! currencyList: [Currency!]! globals: Globals + invitePreview(token: String!): InvitePreview isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload latestAccountUpgradeRequest: AccountUpgradeRequestPayload! lnInvoicePaymentStatus(input: LnInvoicePaymentStatusInput!): LnInvoicePaymentStatusPayload! @@ -2182,6 +2237,19 @@ type RealtimePricePayload realtimePrice: RealtimePrice } +input RedeemInviteInput + @join__type(graph: PUBLIC) +{ + token: String! +} + +type RedeemInvitePayload + @join__type(graph: PUBLIC) +{ + errors: [String!]! + success: Boolean! +} + input RequestCashoutInput @join__type(graph: PUBLIC) { diff --git a/dev/bin/gen-test-jwt.ts b/dev/bin/gen-test-jwt.ts index fbeb6ef5a..d2e5044e4 100644 --- a/dev/bin/gen-test-jwt.ts +++ b/dev/bin/gen-test-jwt.ts @@ -19,7 +19,29 @@ const jwk = jwks.keys[0] const keystore = jose.JWK.createKeyStore() const isDev = true +// Admin JWT configuration +const ADMIN_JWT_SECRET = process.env.ERPNEXT_JWT_SECRET || "not-so-secret" + async function main() { + // Check if --admin flag is passed + const isAdminToken = process.argv.includes("--admin") + + if (isAdminToken) { + // Generate admin JWT token + const adminToken = genAdminToken() + console.log("\n=== Admin JWT Token ===") + console.log("Token:", adminToken) + console.log("\nTo use this token, add it to your GraphQL request headers:") + console.log("Authorization: Bearer", adminToken) + console.log("\nExample curl command:") + console.log(`curl -X POST http://localhost:4001/graphql \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${adminToken}" \\ + -d '{"query":"{ invitesList { edges { node { id contact status } } } }"}'`) + return + } + + // Original Firebase token generation const token = await genToken({ sub, aud, @@ -37,6 +59,23 @@ async function main() { // console.log("verifiedToken:", verifiedToken) } +function genAdminToken(): string { + // Create admin JWT payload with required fields + const payload = { + userId: "admin-test-user", + roles: ["Accounts Manager"], // Required role for admin access + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // Expires in 24 hours + } + + // Sign the token with the admin secret + const token = jsonwebtoken.sign(payload, ADMIN_JWT_SECRET, { + algorithm: "HS256", + }) + + return token +} + async function genToken(payload) { // Create a JWT without an expiration time const options = { @@ -86,12 +125,12 @@ async function verifyToken(token) { const pem = jwtAskey.toPEM(false) // Verify the token - // const verifiedToken = jsonwebtoken.verify(token, pem, { - // algorithms: ["RS256"], - // audience: aud, - // issuer: iss, - // }) - // return verifiedToken + const verifiedToken = jsonwebtoken.verify(token, pem, { + algorithms: ["RS256"], + audience: aud as any, + issuer: iss, + }) + return verifiedToken } main() diff --git a/src/app/admin/index.ts b/src/app/admin/index.ts index 7ef6f70e6..a8c42d4e7 100644 --- a/src/app/admin/index.ts +++ b/src/app/admin/index.ts @@ -2,6 +2,10 @@ export * from "./update-user-phone" // export * from "./send-admin-push-notification" // export * from "./send-broadcast-notification" export * from "./send-cashout-notification" +export * from "./invite" + +// Re-export query functions from invite module for admin GraphQL compatibility +export { getInviteById, listInvites } from "../invite/queries" import { checkedToAccountUuid, checkedToUsername } from "@domain/accounts" import { IdentityRepository } from "@services/kratos" diff --git a/src/app/admin/invite.ts b/src/app/admin/invite.ts new file mode 100644 index 000000000..36703068b --- /dev/null +++ b/src/app/admin/invite.ts @@ -0,0 +1,183 @@ +import { InviteRepository } from "@services/mongoose/models/invite" +import { + InviteStatus, + InviteId, + InviteAlreadyAcceptedError, + InvalidExpirationDateError, + DAILY_INVITE_LIMIT, + TARGET_INVITE_LIMIT, +} from "@domain/invite" +import { RateLimitPrefix } from "@domain/rate-limit" +import { redis } from "@services/redis" +import { UnknownRepositoryError, CouldNotFindError } from "@domain/errors" + +export const revokeInvite = async (inviteId: InviteId, reason?: string) => { + try { + const invite = await InviteRepository.findById(inviteId) + if (!invite) { + return new CouldNotFindError(`Invite not found: ${inviteId}`) + } + + if (invite.status === InviteStatus.ACCEPTED) { + return new InviteAlreadyAcceptedError("Cannot revoke an already accepted invite") + } + + invite.status = InviteStatus.EXPIRED + invite.revokedAt = new Date() + invite.revokeReason = reason + await invite.save() + + return { + id: invite._id.toString(), + contact: invite.contact, + method: invite.method, + status: invite.status, + inviterAccountId: invite.inviterId.toString(), + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + } + } catch (error) { + return new UnknownRepositoryError(error) + } +} + +export const extendInvite = async (inviteId: InviteId, newExpiresAt: Date) => { + try { + const invite = await InviteRepository.findById(inviteId) + if (!invite) { + return new CouldNotFindError(`Invite not found: ${inviteId}`) + } + + if (invite.status === InviteStatus.ACCEPTED) { + return new InviteAlreadyAcceptedError("Cannot extend an already accepted invite") + } + + // Validate new expiration is in the future + if (newExpiresAt <= new Date()) { + return new InvalidExpirationDateError("New expiration date must be in the future") + } + + invite.expiresAt = newExpiresAt + invite.status = InviteStatus.PENDING // Reset status if it was expired + await invite.save() + + return { + id: invite._id.toString(), + contact: invite.contact, + method: invite.method, + status: invite.status, + inviterAccountId: invite.inviterId.toString(), + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + } + } catch (error) { + return new UnknownRepositoryError(error) + } +} + +export const resetInviteRateLimit = async (accountId: AccountId) => { + try { + // Clear the rate limit key for this account (matches rate-limiter-flexible key format) + const dailyKey = `${RateLimitPrefix.inviteCreate}:${accountId}` + + // Delete the daily limit key + await redis.del(dailyKey) + + return true + } catch (error) { + return new UnknownRepositoryError(error) + } +} + +export const resetInviteTargetRateLimit = async (contact: string) => { + try { + // Clear the target rate limit key for this contact (matches rate-limiter-flexible key format) + const targetKey = `${RateLimitPrefix.inviteTarget}:${contact}` + + // Delete the target limit key + await redis.del(targetKey) + + return true + } catch (error) { + return new UnknownRepositoryError(error) + } +} + +export const resetAllInviteRateLimits = async () => { + try { + // Use SCAN instead of KEYS for production safety + // Match the rate-limiter-flexible key format + const patterns = [ + `${RateLimitPrefix.inviteCreate}:*`, + `${RateLimitPrefix.inviteTarget}:*`, + ] + const allKeys: string[] = [] + + for (const pattern of patterns) { + let cursor = "0" + do { + const result = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100) + cursor = result[0] + const keys = result[1] + allKeys.push(...keys) + } while (cursor !== "0") + } + + if (allKeys.length > 0) { + // Delete in batches of 100 to avoid overloading Redis + const batchSize = 100 + for (let i = 0; i < allKeys.length; i += batchSize) { + const batch = allKeys.slice(i, i + batchSize) + await redis.del(...batch) + } + } + + return true + } catch (error) { + return new UnknownRepositoryError(error) + } +} + +export const getInviteRateLimitStatus = async ({ + accountId, + contact, +}: { + accountId?: AccountId + contact?: string +}) => { + try { + let dailyCount: number | null = null + let dailyTtl: number | null = null + let targetCount: number | null = null + let targetTtl: number | null = null + + if (accountId) { + const dailyKey = `${RateLimitPrefix.inviteCreate}:${accountId}` + const count = await redis.get(dailyKey) + dailyCount = count ? parseInt(count) : 0 + const ttl = await redis.ttl(dailyKey) + dailyTtl = ttl > 0 ? ttl : null + } + + if (contact) { + const targetKey = `${RateLimitPrefix.inviteTarget}:${contact}` + const count = await redis.get(targetKey) + targetCount = count ? parseInt(count) : 0 + const ttl = await redis.ttl(targetKey) + targetTtl = ttl > 0 ? ttl : null + } + + return { + accountId, + contact, + dailyCount, + dailyLimit: DAILY_INVITE_LIMIT, + targetCount, + targetLimit: TARGET_INVITE_LIMIT, + dailyTtl, + targetTtl, + } + } catch (error) { + return new UnknownRepositoryError(error) + } +} diff --git a/src/app/invite/award-referral-reward.ts b/src/app/invite/award-referral-reward.ts new file mode 100644 index 000000000..620ae9bc0 --- /dev/null +++ b/src/app/invite/award-referral-reward.ts @@ -0,0 +1,248 @@ +import { getReferralRewardConfig } from "@config" + +import { InviteStatus } from "@domain/invite" +import { referralRewardAmountCents } from "@domain/invite/referral-reward" +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { WalletCurrency } from "@domain/shared" + +import { AccountsRepository, WalletsRepository } from "@services/mongoose" +import { InviteRepository } from "@services/mongoose/models/invite" +import { nextReferralRewardSeq } from "@services/mongoose/models/referral-reward-counter" +import { baseLogger } from "@services/logger" + +const REWARDS_ROLE = "rewards" + +type PartyPayResult = "paid" | "pending" | "failed" + +const walletsFor = async (accountId: AccountId): Promise => { + const wallets = await WalletsRepository().listByAccountId(accountId) + return wallets instanceof Error ? [] : wallets +} + +const markReward = async ( + inviteId: unknown, + update: Record, +): Promise => { + await InviteRepository.updateOne({ _id: inviteId }, { $set: update }) +} + +// Fired when an invited user's Bridge KYC is approved (they now have a US +// account). Pays a tiered referral reward, in USD cents, to BOTH the inviter +// and the invitee, funded from the account holding the "rewards" role. +// +// Guarantees: +// - Idempotent & fail-closed: an atomic claim on the invite (absent -> +// "processing") ensures a referral is processed once; a party is never +// paid twice. A failed/partial payout is recorded (not retried) for manual +// reconciliation rather than risking a double-pay. +// - No stranded claims: any unexpected throw after the claim downgrades it to +// "failed" with a rewardError; `rewardClaimedAt` is stamped at claim time so +// an ops sweep can find rows stuck in "processing" (e.g. pod killed +// mid-payout before any terminal mark landed). +// - Never throws into the KYC path: all errors are caught and logged. +export const awardReferralRewardOnKycApproval = async ({ + accountId, +}: { + accountId: AccountId +}): Promise => { + try { + const config = getReferralRewardConfig() + if (!config.enabled) return + + // One reward per invitee, ever. Bridge KYC can flap back to "approved" + // (approved -> under_review -> approved re-fires this hook), and redemption + // history may hold more than one accepted invite — if ANY invite for this + // account was already claimed for a reward, never pay a second one. + const alreadyProcessed = await InviteRepository.exists({ + redeemedById: accountId, + rewardStatus: { $exists: true }, + }) + if (alreadyProcessed) return + + // Only accepted invites that have not yet been claimed for a reward. + const pending = await InviteRepository.findOne({ + redeemedById: accountId, + status: InviteStatus.ACCEPTED, + rewardStatus: { $exists: false }, + }) + if (!pending) return // not a referred user, or already claimed + + // Atomic claim — only one caller flips absent -> "processing". + const invite = await InviteRepository.findOneAndUpdate( + { _id: pending._id, rewardStatus: { $exists: false } }, + { $set: { rewardStatus: "processing", rewardClaimedAt: new Date() } }, + { new: true }, + ) + if (!invite) return // lost the race to a concurrent caller + + // The claim is ours from here: an unexpected throw must not strand an + // invisible "processing" row, so the remainder runs under its own catch + // that downgrades the claim to "failed" for reconciliation. Party results + // are hoisted so the catch can preserve evidence of any payment that + // already went out before the throw (a re-pay must never look safe). + let seq: number | undefined + let inviterResult: PartyPayResult = "failed" + let inviteeResult: PartyPayResult = "failed" + try { + // Reserve the global sequence number and resolve this referral's amount. + seq = await nextReferralRewardSeq() + const amountCents = referralRewardAmountCents(config.tiers, seq) + + if (amountCents <= 0) { + await markReward(invite._id, { + rewardStatus: "paid", + rewardSeq: seq, + rewardAmountCents: 0, + rewardedAt: new Date(), + }) + return + } + + const inviterAccountId = invite.inviterId.toString() as AccountId + const inviteeAccountId = (invite.redeemedById?.toString() ?? accountId) as AccountId + + // Resolve the funding wallet: prefer the USDT wallet (the active cash + // wallet on every account — see accounts/create-account.ts), falling + // back to the legacy USD wallet. + const rewardsAccount = await AccountsRepository().findByRole(REWARDS_ROLE) + if (rewardsAccount instanceof Error) { + await markReward(invite._id, { + rewardStatus: "failed", + rewardSeq: seq, + rewardAmountCents: amountCents, + rewardError: "rewards account not configured", + }) + baseLogger.error( + { accountId, seq }, + "referral reward: no account holds the 'rewards' role", + ) + return + } + const rewardsWallets = await walletsFor(rewardsAccount.id) + const rewardsWallet = + rewardsWallets.find((w) => w.currency === WalletCurrency.Usdt) ?? + rewardsWallets.find((w) => w.currency === WalletCurrency.Usd) + if (!rewardsWallet) { + await markReward(invite._id, { + rewardStatus: "failed", + rewardSeq: seq, + rewardAmountCents: amountCents, + rewardError: "rewards account has no USDT or USD wallet", + }) + baseLogger.error( + { accountId, seq }, + "referral reward: rewards account has no USDT or USD wallet", + ) + return + } + + // Recipients must hold a wallet in the funding wallet's currency — + // send-intraledger rejects cross-currency sends. + const payoutCurrency = rewardsWallet.currency + const walletIdWithPayoutCurrency = async (recipientAccountId: AccountId) => + (await walletsFor(recipientAccountId)).find((w) => w.currency === payoutCurrency) + ?.id + + const inviterWalletId = await walletIdWithPayoutCurrency(inviterAccountId) + const inviteeWalletId = await walletIdWithPayoutCurrency(inviteeAccountId) + const memo = `Flash referral reward (#${seq})` + + const payParty = async ( + recipientWalletId: WalletId | undefined, + ): Promise => { + if (!recipientWalletId) return "failed" + // Lazy-import so merely importing @app/invite doesn't pull the IBEX + // client (and its module-load side effects) into unrelated code paths. + const { intraledgerPaymentSendWalletIdForUsdWallet } = await import( + "@app/payments/send-intraledger" + ) + const result = await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: rewardsWallet.id, + recipientWalletId, + amount: amountCents, + memo, + }) + if (result instanceof Error) { + baseLogger.error( + { err: result, recipientWalletId, seq }, + "referral reward: payout returned an error", + ) + return "failed" + } + if (result === PaymentSendStatus.Success) return "paid" + // An IBEX-pending send has probably left the funding wallet: mark the + // party rewarded (never risk a double-pay on a re-run) but keep the + // invite in a non-terminal "pending" status so ops re-checks it. + if (result === PaymentSendStatus.Pending) return "pending" + return "failed" + } + + // Pay each party independently so a single failure can't undo the other. + inviterResult = await payParty(inviterWalletId) + inviteeResult = await payParty(inviteeWalletId) + + const now = new Date() + const rewardStatus = + inviterResult === "paid" && inviteeResult === "paid" + ? "paid" + : inviterResult === "failed" && inviteeResult === "failed" + ? "failed" + : inviterResult === "failed" || inviteeResult === "failed" + ? "partial" + : "pending" + + const update: Record = { + rewardStatus, + rewardSeq: seq, + rewardAmountCents: amountCents, + } + // Timestamps are set for pending parties too: the money has probably + // moved, and a missing timestamp must never invite a second payment. + if (inviterResult !== "failed") update.inviterRewardedAt = now + if (inviteeResult !== "failed") update.inviteeRewardedAt = now + if (rewardStatus === "paid") update.rewardedAt = now + else { + update.rewardError = + `inviter=${inviterResult} invitee=${inviteeResult} ` + + `currency=${payoutCurrency} inviterWallet=${Boolean(inviterWalletId)} ` + + `inviteeWallet=${Boolean(inviteeWalletId)}` + } + await markReward(invite._id, update) + + if (rewardStatus === "paid") { + baseLogger.info( + { accountId, seq, amountCents }, + "referral reward paid to both parties", + ) + } else { + baseLogger.error( + { accountId, seq, rewardStatus, update }, + "referral reward not fully paid — needs manual reconciliation", + ) + } + } catch (err) { + // Downgrade the claim so the row is visible to reconciliation instead of + // stranded in "processing" forever — preserving evidence of any party + // already paid before the throw so reconciliation can't double-pay them. + baseLogger.error( + { err, accountId, seq, inviterResult, inviteeResult }, + "referral reward: unexpected error after claim", + ) + const anyPartyPaid = inviterResult !== "failed" || inviteeResult !== "failed" + const update: Record = { + rewardStatus: anyPartyPaid ? "partial" : "failed", + rewardError: + `unexpected: ${String(err)} ` + + `(inviter=${inviterResult} invitee=${inviteeResult})`, + ...(seq !== undefined ? { rewardSeq: seq } : {}), + } + const now = new Date() + if (inviterResult !== "failed") update.inviterRewardedAt = now + if (inviteeResult !== "failed") update.inviteeRewardedAt = now + await markReward(invite._id, update) + } + } catch (err) { + // A reward failure must never break KYC approval. + baseLogger.error({ err, accountId }, "referral reward: unexpected error") + } +} diff --git a/src/app/invite/index.ts b/src/app/invite/index.ts new file mode 100644 index 000000000..31bf1d6f8 --- /dev/null +++ b/src/app/invite/index.ts @@ -0,0 +1,118 @@ +import { InviteRepository } from "@services/mongoose/models/invite" +import { + InviteStatus, + InviteMethod, + INVITE_EXPIRY_HOURS, + validateContactForMethod, +} from "@domain/invite" +import { AccountsRepository } from "@services/mongoose" +import { UnknownRepositoryError } from "@domain/errors" +import { ValidationError } from "@domain/shared" +import { checkedToAccountId } from "@domain/accounts" +import { sendInviteNotification } from "@services/notifications/invite" +import { generateInviteToken } from "@utils" + +import { checkInviteCreateRateLimit, checkInviteTargetRateLimit } from "./rate-limits" + +export { getInviteById, listInvites } from "./queries" +export { awardReferralRewardOnKycApproval } from "./award-referral-reward" + +export const createInvite = async ({ + accountId, + contact, + method, +}: { + accountId: AccountId + contact: string + method: InviteMethod +}) => { + try { + // Validate contact format + const contactValidation = validateContactForMethod(contact, method) + if (contactValidation instanceof ValidationError) { + return contactValidation + } + + // Check rate limits + const dailyLimitCheck = await checkInviteCreateRateLimit(accountId) + if (dailyLimitCheck instanceof Error) { + return new ValidationError("Daily invite limit exceeded") + } + + const targetLimitCheck = await checkInviteTargetRateLimit(contact) + if (targetLimitCheck instanceof Error) { + return new ValidationError( + "This contact has already been invited by multiple users", + ) + } + + // Check for duplicate invite + const existingInvite = await InviteRepository.findOne({ + inviterId: accountId, + contact, + status: { $in: [InviteStatus.PENDING, InviteStatus.SENT] }, + }) + if (existingInvite) { + return new ValidationError("This contact has already been invited") + } + + // Get inviter account for username + const accounts = AccountsRepository() + const inviterAccountId = checkedToAccountId(accountId) + if (inviterAccountId instanceof Error) return inviterAccountId + + const inviterAccount = await accounts.findById(inviterAccountId) + if (inviterAccount instanceof Error) return inviterAccount + + // Generate secure token (20 bytes = 40 hex chars) + const { token, tokenHash } = generateInviteToken() + + // Create invite + const expiresAt = new Date() + expiresAt.setHours(expiresAt.getHours() + INVITE_EXPIRY_HOURS) + + const invite = new InviteRepository({ + contact, + method, + tokenHash, + inviterId: accountId, + status: InviteStatus.PENDING, + createdAt: new Date(), + expiresAt, + }) + + await invite.save() + + // Send notification with username + const senderName = inviterAccount.username || "A friend" + const sent = await sendInviteNotification({ + method, + contact, + token, + senderName, + }) + + // A failed send must not burn the invite: the PENDING/SENT duplicate check + // above would otherwise block re-inviting this contact for 24h with no + // retry path. Delete the doc so the user can simply try again. + if (!sent) { + await InviteRepository.deleteOne({ _id: invite._id }) + return new ValidationError("Failed to send invitation — please try again") + } + + // Update status to SENT + invite.status = InviteStatus.SENT + await invite.save() + + return { + id: invite._id.toString(), + contact: invite.contact, + method: invite.method, + status: invite.status, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + } + } catch (error) { + return new UnknownRepositoryError(error) + } +} diff --git a/src/app/invite/queries.ts b/src/app/invite/queries.ts new file mode 100644 index 000000000..c98db9495 --- /dev/null +++ b/src/app/invite/queries.ts @@ -0,0 +1,126 @@ +import mongoose from "mongoose" + +import { InviteRepository } from "@services/mongoose/models/invite" +import { AccountsRepository } from "@services/mongoose" +import { InviteStatus, InviteId } from "@domain/invite" +import { UnknownRepositoryError, CouldNotFindError } from "@domain/errors" +import { checkedToAccountId } from "@domain/accounts" + +export const getInviteById = async (id: InviteId) => { + try { + const invite = await InviteRepository.findById(id) + if (!invite) { + return new CouldNotFindError(`Invite not found: ${id}`) + } + + // Get inviter account details + const inviterAccountId = checkedToAccountId(invite.inviterId.toString()) + if (inviterAccountId instanceof Error) return inviterAccountId + + const inviterAccount = await AccountsRepository().findById(inviterAccountId) + if (inviterAccount instanceof Error) return inviterAccount + + // Get redeemer account if invite was redeemed + let redeemerAccountId: string | undefined + let redeemerUsername: string | undefined + if (invite.status === InviteStatus.ACCEPTED && invite.redeemedById) { + const redeemerAccId = checkedToAccountId(invite.redeemedById.toString()) + if (!(redeemerAccId instanceof Error)) { + const account = await AccountsRepository().findById(redeemerAccId) + if (!(account instanceof Error)) { + redeemerAccountId = account.id + redeemerUsername = account.username || undefined + } + } + } + + return { + id: invite._id.toString(), + contact: invite.contact, + method: invite.method, + status: invite.status, + inviterAccountId: invite.inviterId.toString(), + inviterUsername: inviterAccount.username, + redeemerAccountId, + redeemerUsername, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt, + redeemedAt: invite.redeemedAt, + rewardStatus: invite.rewardStatus, + rewardAmountCents: invite.rewardAmountCents, + rewardedAt: invite.rewardedAt, + } + } catch (error) { + return new UnknownRepositoryError(error) + } +} + +export const listInvites = async ({ + first = 20, + afterId, + status, + inviterId, +}: { + first?: number + // _id cursor: return invites strictly older than this id (ObjectIds are + // time-ordered, matching the newest-first sort). + afterId?: string + status?: InviteStatus + inviterId?: AccountId +}) => { + try { + const matchQuery: Record = {} + + if (status) { + matchQuery.status = status + } + + if (inviterId) { + // mongoose does not cast inside aggregation pipelines — a raw string + // would silently match nothing against the ObjectId field. + matchQuery.inviterId = new mongoose.Types.ObjectId(inviterId) + } + + const cursorMatch = afterId + ? [{ $match: { _id: { $lt: new mongoose.Types.ObjectId(afterId) } } }] + : [] + + const [result] = await InviteRepository.aggregate([ + { $match: matchQuery }, + { + $facet: { + // count covers everything matching the filter; only the data page + // is cursor-restricted. + data: [ + ...cursorMatch, + { $sort: { _id: -1 } }, + { $limit: first }, + { + $project: { + id: { $toString: "$_id" }, + contact: 1, + method: 1, + status: 1, + inviterAccountId: { $toString: "$inviterId" }, + createdAt: 1, + expiresAt: 1, + redeemedAt: 1, + rewardStatus: 1, + rewardAmountCents: 1, + rewardedAt: 1, + }, + }, + ], + count: [{ $count: "total" }], + }, + }, + ]) + + return { + data: result.data || [], + count: result.count || [{ total: 0 }], + } + } catch (error) { + return new UnknownRepositoryError(error) + } +} diff --git a/src/app/invite/rate-limits.ts b/src/app/invite/rate-limits.ts new file mode 100644 index 000000000..bdff38b21 --- /dev/null +++ b/src/app/invite/rate-limits.ts @@ -0,0 +1,22 @@ +import { RateLimitConfig } from "@domain/rate-limit" +import { + InviteCreateRateLimiterExceededError, + InviteTargetRateLimiterExceededError, +} from "@domain/rate-limit/errors" +import { consumeLimiter } from "@services/rate-limit" + +export const checkInviteCreateRateLimit = async ( + accountId: AccountId, +): Promise => + consumeLimiter({ + rateLimitConfig: RateLimitConfig.inviteCreate, + keyToConsume: accountId, + }) + +export const checkInviteTargetRateLimit = async ( + contact: string, +): Promise => + consumeLimiter({ + rateLimitConfig: RateLimitConfig.inviteTarget, + keyToConsume: contact as IpAddress, // Contact string used as rate limit key + }) diff --git a/src/config/env.ts b/src/config/env.ts index cd56b371c..484a54e55 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -42,6 +42,13 @@ export const env = createEnv({ TWILIO_ACCOUNT_SID: z.string().min(1), TWILIO_AUTH_TOKEN: z.string().min(1), TWILIO_VERIFY_SERVICE_ID: z.string().min(1), + TWILIO_FROM: z.string().optional(), + TWILIO_WHATSAPP_FROM: z.string().optional(), + + FIREBASE_DYNAMIC_LINK_DOMAIN: z.string().optional(), + APP_INSTALL_URL: z.string().url().optional(), + ANDROID_PACKAGE_NAME: z.string().optional(), + IOS_BUNDLE_ID: z.string().optional(), KRATOS_PUBLIC_API: z.string().url(), KRATOS_ADMIN_API: z.string().url(), @@ -175,6 +182,13 @@ export const env = createEnv({ TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN, TWILIO_VERIFY_SERVICE_ID: process.env.TWILIO_VERIFY_SERVICE_ID, + TWILIO_FROM: process.env.TWILIO_FROM, + TWILIO_WHATSAPP_FROM: process.env.TWILIO_WHATSAPP_FROM, + + FIREBASE_DYNAMIC_LINK_DOMAIN: process.env.FIREBASE_DYNAMIC_LINK_DOMAIN, + APP_INSTALL_URL: process.env.APP_INSTALL_URL, + ANDROID_PACKAGE_NAME: process.env.ANDROID_PACKAGE_NAME, + IOS_BUNDLE_ID: process.env.IOS_BUNDLE_ID, KRATOS_PUBLIC_API: process.env.KRATOS_PUBLIC_API, KRATOS_ADMIN_API: process.env.KRATOS_ADMIN_API, diff --git a/src/config/index.ts b/src/config/index.ts index 575991953..9a1c2733f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -8,6 +8,8 @@ import { } from "./yaml" import { env } from "./env" +export { env } + export * from "./error" export * from "./yaml" export * from "./schema" @@ -141,6 +143,8 @@ export const PRICE_SERVER_HOST = env.PRICE_SERVER_HOST export const TWILIO_ACCOUNT_SID = env.TWILIO_ACCOUNT_SID export const TWILIO_AUTH_TOKEN = env.TWILIO_AUTH_TOKEN export const TWILIO_VERIFY_SERVICE_ID = env.TWILIO_VERIFY_SERVICE_ID +export const TWILIO_FROM = env.TWILIO_FROM +export const TWILIO_WHATSAPP_FROM = env.TWILIO_WHATSAPP_FROM export const KRATOS_PUBLIC_API = env.KRATOS_PUBLIC_API export const KRATOS_ADMIN_API = env.KRATOS_ADMIN_API export const KRATOS_MASTER_USER_PASSWORD = env.KRATOS_MASTER_USER_PASSWORD diff --git a/src/config/schema.ts b/src/config/schema.ts index 401116fe2..b6cd15a04 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -162,6 +162,43 @@ export const configSchema = { denyASNs: [], }, }, + referralReward: { + type: "object", + properties: { + // Off by default: no payouts happen until ops assigns a `rewards` + // wallet and flips this on. + enabled: { type: "boolean", default: false }, + // Cumulative tiers: seq 1..100 -> $5, 101..600 -> $2.50, 601+ -> $1. + // upToCount <= 0 marks the final unbounded tier. + tiers: { + type: "array", + items: { + type: "object", + properties: { + upToCount: { type: "integer" }, + amountCents: { type: "integer" }, + }, + required: ["upToCount", "amountCents"], + additionalProperties: false, + }, + default: [ + { upToCount: 100, amountCents: 500 }, + { upToCount: 600, amountCents: 250 }, + { upToCount: 0, amountCents: 100 }, + ], + }, + }, + required: ["enabled", "tiers"], + additionalProperties: false, + default: { + enabled: false, + tiers: [ + { upToCount: 100, amountCents: 500 }, + { upToCount: 600, amountCents: 250 }, + { upToCount: 0, amountCents: 100 }, + ], + }, + }, coldStorage: { type: "object", properties: { diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 8930498e2..1de97bec3 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -107,6 +107,10 @@ type YamlSchema = { denyASNs: string[] allowASNs: string[] } + referralReward: { + enabled: boolean + tiers: { upToCount: number; amountCents: number }[] + } coldStorage: { minOnChainHotWalletBalance: number minRebalanceSize: number diff --git a/src/config/yaml.ts b/src/config/yaml.ts index 79710de7d..b4a2e7f14 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -18,6 +18,7 @@ import { toDays, toSeconds } from "@domain/primitives" import { BigIntConversionError, JMDAmount, WalletCurrency } from "@domain/shared" import { AccountLevel } from "@domain/accounts" +import { DAILY_INVITE_LIMIT, TARGET_INVITE_LIMIT } from "@domain/invite" import mergeWith from "lodash.mergewith" @@ -217,6 +218,18 @@ export const getInvoiceCreateForRecipientAttemptLimits = () => export const getOnChainAddressCreateAttemptLimits = () => getRateLimits(yamlConfig.rateLimits.onChainAddressCreateAttempt) +export const getInviteCreateAttemptLimits = () => ({ + points: DAILY_INVITE_LIMIT, + duration: toSeconds(86400), // 24 hours + blockDuration: toSeconds(86400), // 24 hours +}) + +export const getInviteTargetAttemptLimits = () => ({ + points: TARGET_INVITE_LIMIT, + duration: toSeconds(86400), // 24 hours + blockDuration: toSeconds(86400), // 24 hours +}) + export const getOnChainWalletConfig = () => ({ dustThreshold: yamlConfig.onChainWallet.dustThreshold, }) @@ -275,6 +288,14 @@ export const getFCMTopics = (config = yamlConfig): string[] => export const getCaptcha = (config = yamlConfig): CaptchaConfig => config.captcha +export const getReferralRewardConfig = (): { + enabled: boolean + tiers: { upToCount: number; amountCents: number }[] +} => ({ + enabled: yamlConfig.referralReward?.enabled ?? false, + tiers: yamlConfig.referralReward?.tiers ?? [], +}) + export const getRewardsConfig = (): RewardsConfig => { const denyPhoneCountries = yamlConfig.rewards.denyPhoneCountries || [] const allowPhoneCountries = yamlConfig.rewards.allowPhoneCountries || [] diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index 50ca8c586..0017d6e19 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -236,9 +236,11 @@ interface IAccountsRepository { findByBridgeEthereumAddress(address: string): Promise findByBridgeCustomerId(customerId: BridgeCustomerId): Promise + + findByRole(role: string): Promise } -type AdminRole = "dealer" | "funder" | "bankowner" | "editor" +type AdminRole = "dealer" | "funder" | "bankowner" | "editor" | "rewards" type AdminAccount = { role: AdminRole phone: PhoneNumber diff --git a/src/domain/accounts/primitives.ts b/src/domain/accounts/primitives.ts index 0b4cb4b09..f8626a36b 100644 --- a/src/domain/accounts/primitives.ts +++ b/src/domain/accounts/primitives.ts @@ -36,4 +36,5 @@ export const AccountRoles = { bankowner: "bankowner", user: "user", editor: "editor", + rewards: "rewards", // funding account for referral reward payouts } diff --git a/src/domain/api-keys/scope-map.ts b/src/domain/api-keys/scope-map.ts index e806878f4..b92f91b6d 100644 --- a/src/domain/api-keys/scope-map.ts +++ b/src/domain/api-keys/scope-map.ts @@ -41,6 +41,8 @@ export const apiKeyScopeForField: Readonly> = deviceNotificationTokenCreate: "BLOCKED", businessAccountUpgradeRequest: "BLOCKED", accountCapabilityUpgradeRequest: "BLOCKED", + createInvite: "BLOCKED", + redeemInvite: "BLOCKED", bankAccountUpdateRequest: "BLOCKED", accountDelete: "BLOCKED", feedbackSubmit: "BLOCKED", diff --git a/src/domain/invite/index.ts b/src/domain/invite/index.ts new file mode 100644 index 000000000..27e0eafa6 --- /dev/null +++ b/src/domain/invite/index.ts @@ -0,0 +1,42 @@ +export { InviteStatus, InviteMethod } from "@services/mongoose/models/invite" +export type { InviteRecord } from "@services/mongoose/models/invite" +export { validateEmail, validatePhone, validateContactForMethod } from "./validation" + +import { ValidationError } from "@domain/shared" + +export const INVITE_EXPIRY_HOURS = 24 +export const DAILY_INVITE_LIMIT = 10 +export const TARGET_INVITE_LIMIT = 3 +export const NEW_USER_INVITE_WINDOW_HOURS = 24 // New users can redeem invites within this window after account creation +export const INVITE_TOKEN_LENGTH = 40 // 20 bytes = 40 hex characters + +// Branded type for InviteId +export type InviteId = string & { readonly brand: unique symbol } + +// Domain-specific error for invite validation +export class InvalidInviteIdError extends ValidationError {} +export class InviteAlreadyAcceptedError extends ValidationError {} +export class InvalidExpirationDateError extends ValidationError {} + +// Helper function to convert string to InviteId +export const checkedToInviteId = (inviteId: string): InviteId | ValidationError => { + // Basic validation - should be a 24-character MongoDB ObjectId + if (inviteId.length !== 24) { + return new InvalidInviteIdError(`Invalid invite ID format: ${inviteId}`) + } + return inviteId as InviteId +} + +// Branded type for InviteToken +export type InviteToken = string & { readonly brand: unique symbol } + +// Helper function to validate invite token format +export const checkedToInviteToken = (token: string): InviteToken | ValidationError => { + if (!token || token.length !== INVITE_TOKEN_LENGTH) { + return new ValidationError("Invalid invitation token length") + } + if (!/^[a-f0-9]+$/i.test(token)) { + return new ValidationError("Invalid invitation token format") + } + return token as InviteToken +} diff --git a/src/domain/invite/referral-reward.ts b/src/domain/invite/referral-reward.ts new file mode 100644 index 000000000..5fb106214 --- /dev/null +++ b/src/domain/invite/referral-reward.ts @@ -0,0 +1,30 @@ +export interface ReferralRewardTier { + // Inclusive upper bound (cumulative) of the referral sequence for this tier. + // A value <= 0 marks the final, unbounded tier. + upToCount: number + amountCents: number +} + +// Given the ordered tiers and a 1-based referral sequence number, return the +// per-party reward amount (in USD cents) for that referral. +// +// Tiers are cumulative. For the default schedule +// [{ upToCount: 100, amountCents: 500 }, +// { upToCount: 600, amountCents: 250 }, +// { upToCount: 0, amountCents: 100 }] +// seq 1..100 -> 500, 101..600 -> 250, 601+ -> 100. +// +// Fail-safe: past every bounded tier, the final tier's amount applies only if +// that tier is explicitly unbounded (upToCount <= 0). A schedule missing the +// unbounded sentinel pays 0 past its last bound — a misconfiguration must +// never silently over-pay forever. +export const referralRewardAmountCents = ( + tiers: ReferralRewardTier[], + seq: number, +): number => { + for (const tier of tiers) { + if (tier.upToCount > 0 && seq <= tier.upToCount) return tier.amountCents + } + const last = tiers[tiers.length - 1] + return last && last.upToCount <= 0 ? last.amountCents : 0 +} diff --git a/src/domain/invite/validation.ts b/src/domain/invite/validation.ts new file mode 100644 index 000000000..604fb5a81 --- /dev/null +++ b/src/domain/invite/validation.ts @@ -0,0 +1,33 @@ +import { InviteMethod } from "@services/mongoose/models/invite" +import { ValidationError } from "@domain/shared" + +export const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +export const validatePhone = (phone: string): boolean => { + const phoneRegex = /^\+[1-9]\d{7,14}$/ + return phoneRegex.test(phone) +} + +export const validateContactForMethod = ( + contact: string, + method: InviteMethod, +): true | ValidationError => { + switch (method) { + case InviteMethod.EMAIL: + if (!validateEmail(contact)) { + return new ValidationError("Invalid email format") + } + return true + case InviteMethod.SMS: + case InviteMethod.WHATSAPP: + if (!validatePhone(contact)) { + return new ValidationError("Invalid phone number format") + } + return true + default: + return new ValidationError("Invalid invite method") + } +} diff --git a/src/domain/rate-limit/errors.ts b/src/domain/rate-limit/errors.ts index e139302b6..a219ee9bc 100644 --- a/src/domain/rate-limit/errors.ts +++ b/src/domain/rate-limit/errors.ts @@ -16,3 +16,5 @@ export class UserLoginIdentifierRateLimiterExceededError extends RateLimiterExce export class InvoiceCreateRateLimiterExceededError extends RateLimiterExceededError {} export class InvoiceCreateForRecipientRateLimiterExceededError extends RateLimiterExceededError {} export class OnChainAddressCreateRateLimiterExceededError extends RateLimiterExceededError {} +export class InviteCreateRateLimiterExceededError extends RateLimiterExceededError {} +export class InviteTargetRateLimiterExceededError extends RateLimiterExceededError {} diff --git a/src/domain/rate-limit/index.ts b/src/domain/rate-limit/index.ts index 16402ec31..4473ca0a4 100644 --- a/src/domain/rate-limit/index.ts +++ b/src/domain/rate-limit/index.ts @@ -1,6 +1,8 @@ import { getFailedLoginAttemptPerIpLimits, getFailedLoginAttemptPerLoginIdentifierLimits, + getInviteCreateAttemptLimits, + getInviteTargetAttemptLimits, getInvoiceCreateAttemptLimits, getInvoiceCreateForRecipientAttemptLimits, getOnChainAddressCreateAttemptLimits, @@ -9,6 +11,8 @@ import { } from "@config" import { + InviteCreateRateLimiterExceededError, + InviteTargetRateLimiterExceededError, InvoiceCreateForRecipientRateLimiterExceededError, InvoiceCreateRateLimiterExceededError, OnChainAddressCreateRateLimiterExceededError, @@ -26,6 +30,8 @@ export const RateLimitPrefix = { invoiceCreate: "invoice_create", invoiceCreateForRecipient: "invoice_create_for_recipient", onChainAddressCreate: "onchain_address_create", + inviteCreate: "invite_daily", + inviteTarget: "invite_target", } as const export const RateLimitConfig: { [key: string]: RateLimitConfig } = { @@ -64,4 +70,14 @@ export const RateLimitConfig: { [key: string]: RateLimitConfig } = { limits: getOnChainAddressCreateAttemptLimits(), error: OnChainAddressCreateRateLimiterExceededError, }, + inviteCreate: { + key: RateLimitPrefix.inviteCreate, + limits: getInviteCreateAttemptLimits(), + error: InviteCreateRateLimiterExceededError, + }, + inviteTarget: { + key: RateLimitPrefix.inviteTarget, + limits: getInviteTargetAttemptLimits(), + error: InviteTargetRateLimiterExceededError, + }, } diff --git a/src/graphql/admin/queries.ts b/src/graphql/admin/queries.ts index 5d28b7f8b..89d215e72 100644 --- a/src/graphql/admin/queries.ts +++ b/src/graphql/admin/queries.ts @@ -18,6 +18,8 @@ import IdDocumentReadUrlQuery from "./root/query/id-document-read-url" import NotificationTopicsQuery from "./root/query/notification-topics" import BridgeReconciliationOrphansQuery from "./root/query/bridge-reconciliation-orphans" import CashWalletMigrationsQuery from "./root/query/cash-wallet-migrations" +import InvitesListQuery from "./root/query/invites-list" +import InviteByIdQuery from "./root/query/invite-by-id" export const queryFields = { unauthed: {}, @@ -40,6 +42,8 @@ export const queryFields = { bridgeReconciliationOrphans: BridgeReconciliationOrphansQuery, cashWalletCutover: CashWalletCutoverQuery, cashWalletMigrations: CashWalletMigrationsQuery, + invitesList: InvitesListQuery, + inviteById: InviteByIdQuery, }, } diff --git a/src/graphql/admin/root/query/invite-by-id.ts b/src/graphql/admin/root/query/invite-by-id.ts new file mode 100644 index 000000000..f671ed00e --- /dev/null +++ b/src/graphql/admin/root/query/invite-by-id.ts @@ -0,0 +1,22 @@ +import { GT } from "@graphql/index" +import { Admin } from "@app" +import { mapError } from "@graphql/error-map" +import AdminInvite from "@graphql/admin/types/object/admin-invite" + +const InviteByIdQuery = GT.Field({ + type: AdminInvite, + args: { + id: { type: GT.NonNullID }, + }, + resolve: async (_, { id }) => { + const invite = await Admin.getInviteById(id) + + if (invite instanceof Error) { + throw mapError(invite) + } + + return invite + }, +}) + +export default InviteByIdQuery diff --git a/src/graphql/admin/root/query/invites-list.ts b/src/graphql/admin/root/query/invites-list.ts new file mode 100644 index 000000000..98a4c5d56 --- /dev/null +++ b/src/graphql/admin/root/query/invites-list.ts @@ -0,0 +1,65 @@ +import { GT } from "@graphql/index" +import { Admin } from "@app" +import { mapError } from "@graphql/error-map" +import { InputValidationError } from "@graphql/error" +import InvitesConnection from "@graphql/admin/types/object/invites-connection" +import InviteStatus from "@graphql/shared/types/scalar/invite-status" +import { checkedToAccountId } from "@domain/accounts" +import { + connectionFromPaginatedArray, + connectionArgs, + checkedConnectionArgs, +} from "@graphql/connections" + +const InvitesListQuery = GT.Field({ + type: GT.NonNull(InvitesConnection), + args: { + ...connectionArgs, + status: { type: InviteStatus }, + inviterId: { type: GT.ID }, + }, + resolve: async (_, args) => { + const checkedArgs = checkedConnectionArgs(args) + if (checkedArgs instanceof Error) { + throw mapError(checkedArgs) + } + + // Convert inviterId to branded type if provided + let processedInviterId: AccountId | undefined + if (args.inviterId) { + const checkedInviterId = checkedToAccountId(args.inviterId) + if (checkedInviterId instanceof Error) { + throw mapError(checkedInviterId) + } + processedInviterId = checkedInviterId + } + + // Cursors are invite ObjectId hex strings (connectionFromPaginatedArray + // uses item ids as cursors); page by _id, which is time-ordered. + let afterId: string | undefined + if (args.after) { + if (!/^[a-f0-9]{24}$/i.test(args.after)) { + throw mapError(new InputValidationError({ message: "Invalid cursor" })) + } + afterId = args.after + } + + const invites = await Admin.listInvites({ + first: args.first || 20, + afterId, + status: args.status instanceof Error ? undefined : args.status, + inviterId: processedInviterId, + }) + + if (invites instanceof Error) { + throw mapError(invites) + } + + const totalCount = invites.count?.[0]?.total || 0 + const items = invites.data || [] + + return connectionFromPaginatedArray(items, totalCount, checkedArgs) + }, +}) + +export default InvitesListQuery diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index dabac6868..67985f35f 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -52,6 +52,23 @@ input AccountUpdateStatusInput { uid: ID! } +type AdminInvite { + contact: String! + createdAt: Timestamp! + expiresAt: Timestamp! + id: ID! + inviterAccountId: ID! + inviterUsername: Username + method: InviteMethod! + redeemedAt: Timestamp + redeemerAccountId: ID + redeemerUsername: Username + rewardAmountCents: Int + rewardStatus: String + rewardedAt: Timestamp + status: InviteStatus! +} + """ Accounts are core to the Galoy architecture. they have users, and own wallets """ @@ -304,6 +321,37 @@ type InitiationViaOnChain { address: OnChainAddress! } +enum InviteMethod { + EMAIL + SMS + WHATSAPP +} + +enum InviteStatus { + ACCEPTED + EXPIRED + PENDING + SENT +} + +"""A connection to a list of items.""" +type InvitesConnection { + """A list of edges.""" + edges: [InvitesEdge!] + + """Information to aid in pagination.""" + pageInfo: PageInfo! +} + +"""An edge in a connection.""" +type InvitesEdge { + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: AdminInvite! +} + scalar Language type LightningInvoice { @@ -450,6 +498,22 @@ type Query { """Storage key of the ID document file""" fileKey: String! ): IdDocumentReadUrlPayload! + inviteById(id: ID!): AdminInvite + invitesList( + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + inviterId: ID + + """Returns the last n items from the list.""" + last: Int + status: InviteStatus + ): InvitesConnection! lightningInvoice(hash: PaymentHash!): LightningInvoice! lightningPayment(hash: PaymentHash!): LightningPayment! listWalletIds(walletCurrency: WalletCurrency!): [WalletId!]! diff --git a/src/graphql/admin/types/object/admin-invite.ts b/src/graphql/admin/types/object/admin-invite.ts new file mode 100644 index 000000000..6dba844b6 --- /dev/null +++ b/src/graphql/admin/types/object/admin-invite.ts @@ -0,0 +1,55 @@ +import { GT } from "@graphql/index" +import Username from "@graphql/shared/types/scalar/username" +import Timestamp from "@graphql/shared/types/scalar/timestamp" +import InviteMethod from "@graphql/shared/types/scalar/invite-method" +import InviteStatus from "@graphql/shared/types/scalar/invite-status" + +const AdminInvite = GT.Object({ + name: "AdminInvite", + fields: () => ({ + id: { + type: GT.NonNullID, + }, + contact: { + type: GT.NonNull(GT.String), + }, + method: { + type: GT.NonNull(InviteMethod), + }, + status: { + type: GT.NonNull(InviteStatus), + }, + inviterAccountId: { + type: GT.NonNullID, + }, + inviterUsername: { + type: Username, + }, + redeemerAccountId: { + type: GT.ID, + }, + redeemerUsername: { + type: Username, + }, + createdAt: { + type: GT.NonNull(Timestamp), + }, + expiresAt: { + type: GT.NonNull(Timestamp), + }, + redeemedAt: { + type: Timestamp, + }, + rewardStatus: { + type: GT.String, + }, + rewardAmountCents: { + type: GT.Int, + }, + rewardedAt: { + type: Timestamp, + }, + }), +}) + +export default AdminInvite diff --git a/src/graphql/admin/types/object/invites-connection.ts b/src/graphql/admin/types/object/invites-connection.ts new file mode 100644 index 000000000..1255569c0 --- /dev/null +++ b/src/graphql/admin/types/object/invites-connection.ts @@ -0,0 +1,10 @@ +import { connectionDefinitions } from "@graphql/connections" + +import AdminInvite from "./admin-invite" + +export const { connectionType: InvitesConnection } = connectionDefinitions({ + nodeType: AdminInvite, + name: "Invites", +}) + +export default InvitesConnection diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 1810877f1..ba9559e55 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -51,6 +51,8 @@ import UserPhoneRegistrationValidateMutation from "@graphql/public/root/mutation import UserTotpDeleteMutation from "@graphql/public/root/mutation/user-totp-delete" import MerchantMapSuggestMutation from "@graphql/public/root/mutation/merchant-map-suggest" +import CreateInviteMutation from "@graphql/public/root/mutation/create-invite" +import RedeemInviteMutation from "@graphql/public/root/mutation/redeem-invite" import CallbackEndpointAdd from "./root/mutation/callback-endpoint-add" import CallbackEndpointDelete from "./root/mutation/callback-endpoint-delete" @@ -127,6 +129,11 @@ export const mutationFields = { accountDelete: AccountDeleteMutation, feedbackSubmit: FeedbackSubmitMutation, + createInvite: CreateInviteMutation, + // Requires an authenticated session (mobile redeems post-login); keeping + // it authed also puts it behind API-key scope enforcement (BLOCKED). + redeemInvite: RedeemInviteMutation, + callbackEndpointAdd: CallbackEndpointAdd, callbackEndpointDelete: CallbackEndpointDelete, diff --git a/src/graphql/public/queries.ts b/src/graphql/public/queries.ts index ae9148abf..2131dc171 100644 --- a/src/graphql/public/queries.ts +++ b/src/graphql/public/queries.ts @@ -29,6 +29,7 @@ import BridgeExternalAccountsQuery from "./root/query/bridge-external-accounts" import BridgeWithdrawalRequestQuery from "./root/query/bridge-withdrawal-request" import BridgeWithdrawalsQuery from "./root/query/bridge-withdrawals" import ApiKeysQuery from "./root/query/api-keys" +import InvitePreviewQuery from "./root/query/invite-preview" export const queryFields = { unauthed: { @@ -48,6 +49,7 @@ export const queryFields = { isFlashNpub: IsFlashNpubQuery, supportedBanks: SupportedBanksQuery, cashWalletCutover: CashWalletCutoverQuery, + invitePreview: InvitePreviewQuery, }, authed: { atAccountLevel: { diff --git a/src/graphql/public/root/mutation/create-invite.ts b/src/graphql/public/root/mutation/create-invite.ts new file mode 100644 index 000000000..e94f6d578 --- /dev/null +++ b/src/graphql/public/root/mutation/create-invite.ts @@ -0,0 +1,108 @@ +import { GT } from "@graphql/index" +import { InviteMethod, InviteStatus } from "@services/mongoose/models/invite" +import { createInvite } from "@app/invite" +import { baseLogger } from "@services/logger" +import { checkedToAccountId } from "@domain/accounts" + +const InviteMethodEnum = GT.Enum({ + name: "InviteMethod", + values: { + EMAIL: { value: InviteMethod.EMAIL }, + SMS: { value: InviteMethod.SMS }, + WHATSAPP: { value: InviteMethod.WHATSAPP }, + }, +}) + +const InviteStatusEnum = GT.Enum({ + name: "InviteStatus", + values: { + PENDING: { value: InviteStatus.PENDING }, + SENT: { value: InviteStatus.SENT }, + ACCEPTED: { value: InviteStatus.ACCEPTED }, + EXPIRED: { value: InviteStatus.EXPIRED }, + }, +}) + +const InviteType = GT.Object({ + name: "Invite", + fields: () => ({ + id: { type: GT.NonNull(GT.ID) }, + contact: { type: GT.NonNull(GT.String) }, + method: { type: GT.NonNull(InviteMethodEnum) }, + status: { type: GT.NonNull(InviteStatusEnum) }, + createdAt: { type: GT.NonNull(GT.String) }, + expiresAt: { type: GT.NonNull(GT.String) }, + }), +}) + +const CreateInviteInput = GT.Input({ + name: "CreateInviteInput", + fields: () => ({ + contact: { type: GT.NonNull(GT.String) }, + method: { type: GT.NonNull(InviteMethodEnum) }, + }), +}) + +const CreateInvitePayload = GT.Object({ + name: "CreateInvitePayload", + fields: () => ({ + invite: { type: InviteType }, + errors: { type: GT.NonNull(GT.List(GT.NonNull(GT.String))) }, + }), +}) + +const CreateInviteMutation = GT.Field({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(CreateInvitePayload), + args: { + input: { type: GT.NonNull(CreateInviteInput) }, + }, + resolve: async (_, args, { domainAccount }) => { + const { contact, method } = args.input + + if (!domainAccount) { + return { errors: ["Authentication required"], invite: null } + } + + try { + const accountId = checkedToAccountId(domainAccount.id) + if (accountId instanceof Error) { + return { errors: [accountId.message], invite: null } + } + + const result = await createInvite({ + accountId, + contact, + method, + }) + + if (result instanceof Error) { + return { errors: [result.message], invite: null } + } + + return { + errors: [], + invite: { + id: result.id, + contact: result.contact, + method: result.method, + status: result.status, + createdAt: result.createdAt.toISOString(), + expiresAt: result.expiresAt.toISOString(), + }, + } + } catch (error) { + baseLogger.error({ error }, "Failed to create invite") + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred" + return { + errors: [errorMessage], + invite: null, + } + } + }, +}) + +export default CreateInviteMutation diff --git a/src/graphql/public/root/mutation/redeem-invite.ts b/src/graphql/public/root/mutation/redeem-invite.ts new file mode 100644 index 000000000..bb2a500c9 --- /dev/null +++ b/src/graphql/public/root/mutation/redeem-invite.ts @@ -0,0 +1,225 @@ +import { GT } from "@graphql/index" +import { InviteRepository, InviteStatus } from "@services/mongoose/models/invite" +import { NEW_USER_INVITE_WINDOW_HOURS, checkedToInviteToken } from "@domain/invite" +import { hashToken } from "@utils" +import { baseLogger } from "@services/logger" +import mongoose from "mongoose" +import { AccountsRepository, UsersRepository } from "@services/mongoose" + +const RedeemInviteInput = GT.Input({ + name: "RedeemInviteInput", + fields: () => ({ + token: { type: GT.NonNull(GT.String) }, + }), +}) + +const RedeemInvitePayload = GT.Object({ + name: "RedeemInvitePayload", + fields: () => ({ + success: { type: GT.NonNull(GT.Boolean) }, + errors: { type: GT.NonNull(GT.List(GT.NonNull(GT.String))) }, + }), +}) + +const RedeemInviteMutation = GT.Field({ + extensions: { + complexity: 120, + auths: ["AUTHORIZED"], + }, + type: GT.NonNull(RedeemInvitePayload), + args: { + input: { type: GT.NonNull(RedeemInviteInput) }, + }, + resolve: async (_, args, { user, domainAccount }) => { + const { token } = args.input + + // Validate token format + const validatedToken = checkedToInviteToken(token) + if (validatedToken instanceof Error) { + return { success: false, errors: [validatedToken.message] } + } + + // Ensure user is authenticated + if (!user || !domainAccount) { + return { success: false, errors: ["Authentication required to redeem invitation"] } + } + + try { + // Hash the token to find it in the database + const tokenHash = hashToken(token) + + // Find the invite by tokenHash + const invite = await InviteRepository.findOne({ tokenHash }) + + if (!invite) { + return { success: false, errors: ["Invalid or expired invitation"] } + } + + // Check if invite has already been accepted. This MUST precede the + // date-expiry flip: replaying a redeemed invite's token after expiresAt + // must not overwrite ACCEPTED with EXPIRED — that would strand the + // pending reward and (via the one-redemption-per-account invariant) + // permanently cost the account its referral. + if (invite.status === InviteStatus.ACCEPTED) { + return { success: false, errors: ["This invitation has already been used"] } + } + + // Check if invite has expired + if (new Date() > invite.expiresAt) { + invite.status = InviteStatus.EXPIRED + await invite.save() + return { success: false, errors: ["This invitation has expired"] } + } + + // Revoked (admin-expired) invites must not be redeemable even when their + // expiresAt is still in the future. + if (invite.status === InviteStatus.EXPIRED || invite.revokedAt) { + return { success: false, errors: ["This invitation is no longer valid"] } + } + + // Prevent self-redemption + if (invite.inviterId.toString() === domainAccount.id) { + return { success: false, errors: ["You cannot redeem your own invitation"] } + } + + // One redeemed invite per account, ever: the referral reward is paid per + // redeemed invite on KYC approval, so accumulating several accepted + // invites would multiply payouts (KYC status flaps re-fire the award). + const alreadyRedeemed = await InviteRepository.exists({ + redeemedById: new mongoose.Types.ObjectId(domainAccount.id), + status: InviteStatus.ACCEPTED, + }) + if (alreadyRedeemed) { + return { + success: false, + errors: ["You have already redeemed an invitation"], + } + } + + // Check if user account is new (created within the invite window) + const accountsRepo = AccountsRepository() + const account = await accountsRepo.findById(domainAccount.id) + if (account instanceof Error) { + baseLogger.error( + { error: account }, + "Failed to fetch account for invite validation", + ) + return { success: false, errors: ["Failed to validate account"] } + } + + const accountAge = Date.now() - account.createdAt.getTime() + const inviteWindowMs = NEW_USER_INVITE_WINDOW_HOURS * 60 * 60 * 1000 + if (accountAge > inviteWindowMs) { + baseLogger.info( + { + accountId: domainAccount.id, + accountAge, + inviteWindowHours: NEW_USER_INVITE_WINDOW_HOURS, + inviteId: invite._id, + }, + "Existing user attempted to redeem new user invite", + ) + return { success: false, errors: ["This invitation is for new users only"] } + } + + // Validate contact matches (phone or email) + const usersRepo = UsersRepository() + const userDetails = await usersRepo.findById(user.id) + if (userDetails instanceof Error) { + baseLogger.error( + { error: userDetails }, + "Failed to fetch user for invite validation", + ) + return { success: false, errors: ["Failed to validate user"] } + } + + // Check if the invite contact matches user's phone or email + const inviteContact = invite.contact.toLowerCase() + const userPhone = userDetails.phone?.toLowerCase() + + if (invite.method === "SMS" || invite.method === "WHATSAPP") { + if (!userPhone || userPhone !== inviteContact) { + baseLogger.info( + { + inviteContact, + userPhone, + inviteMethod: invite.method, + }, + "Phone number mismatch for invite redemption", + ) + return { + success: false, + errors: ["This invitation was sent to a different phone number"], + } + } + } + // NOTE: Email validation is deferred until email-only registration is available. + // Currently, users can only register with phone numbers, so email invites cannot + // be validated against the redeemer's identity. Once the email-only registration + // feature (feat/email-registration) is merged, this should be implemented to + // verify that email invites are redeemed by the intended recipient. + // See: https://github.com/lnflash/flash/pull/212 + // + // else if (invite.method === "EMAIL") { + // const userEmail = userDetails.email?.toLowerCase() + // if (!userEmail || userEmail !== inviteContact) { + // return { success: false, errors: ["This invitation was sent to a different email address"] } + // } + // } + + // Mark invite as accepted and set redeemer information. The unique + // partial index on redeemedById backstops the check above: a concurrent + // double-redeem loses with a duplicate-key error, treated as already + // redeemed. + invite.status = InviteStatus.ACCEPTED + invite.redeemedAt = new Date() + invite.redeemedById = new mongoose.Types.ObjectId(domainAccount.id) + try { + await invite.save() + } catch (saveError) { + if ((saveError as { code?: number })?.code === 11000) { + return { + success: false, + errors: ["You have already redeemed an invitation"], + } + } + throw saveError + } + + // Log successful redemption + baseLogger.info( + { + inviteId: invite._id, + inviterId: invite.inviterId, + redeemedById: domainAccount.id, + redeemerUsername: domainAccount.username, + contact: invite.contact, + method: invite.method, + }, + "Invite successfully redeemed by new user", + ) + + // The referral reward is NOT paid here: payout is deferred until the + // invitee's Bridge KYC is approved (awardReferralRewardOnKycApproval, + // fired from the Bridge KYC webhook). + + return { + success: true, + errors: [], + } + } catch (error) { + baseLogger.error( + { error, token: token.substring(0, 8) + "...", userId: user.id }, + "Failed to redeem invite", + ) + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred" + return { + success: false, + errors: [errorMessage], + } + } + }, +}) + +export default RedeemInviteMutation diff --git a/src/graphql/public/root/query/invite-preview.ts b/src/graphql/public/root/query/invite-preview.ts new file mode 100644 index 000000000..0f8e4bcdf --- /dev/null +++ b/src/graphql/public/root/query/invite-preview.ts @@ -0,0 +1,99 @@ +import { GT } from "@graphql/index" +import { InviteRepository, InviteStatus } from "@services/mongoose/models/invite" +import { AccountsRepository } from "@services/mongoose" +import { checkedToAccountId } from "@domain/accounts" +import { checkedToInviteToken } from "@domain/invite" +import { hashToken } from "@utils" +import { baseLogger } from "@services/logger" + +const InvitePreview = GT.Object({ + name: "InvitePreview", + fields: () => ({ + contact: { type: GT.NonNull(GT.String) }, // Full contact for intended recipient, masked for others + method: { type: GT.NonNull(GT.String) }, // SMS, EMAIL, WHATSAPP + inviterUsername: { type: GT.String }, + expiresAt: { type: GT.NonNull(GT.String) }, + isValid: { type: GT.NonNull(GT.Boolean) }, + }), +}) + +const InvitePreviewQuery = GT.Field({ + extensions: { + complexity: 120, + }, + type: InvitePreview, + args: { + token: { type: GT.NonNull(GT.String) }, + }, + resolve: async (_, args) => { + const { token } = args + + // Validate token format + const validatedToken = checkedToInviteToken(token) + if (validatedToken instanceof Error) { + throw new Error(validatedToken.message) + } + + try { + // Hash the token to find it in the database + const tokenHash = hashToken(token) + + // Find the invite by tokenHash + const invite = await InviteRepository.findOne({ tokenHash }) + + if (!invite) { + throw new Error("Invalid or expired invitation") + } + + // Check if invite is still valid (revoked invites carry EXPIRED status + // and/or revokedAt regardless of their expiresAt date) + const isExpired = new Date() > invite.expiresAt + const isAlreadyUsed = invite.status === InviteStatus.ACCEPTED + const isRevoked = invite.status === InviteStatus.EXPIRED || !!invite.revokedAt + const isValid = !isExpired && !isAlreadyUsed && !isRevoked + + // Get inviter username + let inviterUsername: string | undefined + const accountsRepo = AccountsRepository() + const inviterAccountId = checkedToAccountId(invite.inviterId.toString()) + if (!(inviterAccountId instanceof Error)) { + const inviterAccount = await accountsRepo.findById(inviterAccountId) + if (!(inviterAccount instanceof Error)) { + inviterUsername = inviterAccount.username + } + } + + // IMPORTANT: Return full contact for the intended recipient + // Since this is accessed with the invite token, only the intended recipient + // should have access to this token, making it safe to return the full contact + // This allows proper pre-filling of registration forms in the mobile app + const contact = invite.contact + + baseLogger.info( + { + inviteId: invite._id, + method: invite.method, + isValid, + returningFullContact: true, + }, + "Invite preview requested - returning full contact for recipient", + ) + + return { + contact, + method: invite.method, + inviterUsername: inviterUsername || "A Flash user", + expiresAt: invite.expiresAt.toISOString(), + isValid, + } + } catch (error) { + baseLogger.error( + { error, token: token.substring(0, 8) + "..." }, + "Failed to get invite preview", + ) + throw new Error("Unable to preview invitation") + } + }, +}) + +export default InvitePreviewQuery diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 5d74ff85b..370b2daba 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -769,6 +769,16 @@ type Country { """A CCA2 country code (ex US, FR, etc)""" scalar CountryCode +input CreateInviteInput { + contact: String! + method: InviteMethod! +} + +type CreateInvitePayload { + errors: [String!]! + invite: Invite +} + type Currency { flag: String! fractionDigits: Int! @@ -970,6 +980,36 @@ input IntraLedgerUsdPaymentSendInput { walletId: WalletId! } +type Invite { + contact: String! + createdAt: String! + expiresAt: String! + id: ID! + method: InviteMethod! + status: InviteStatus! +} + +enum InviteMethod { + EMAIL + SMS + WHATSAPP +} + +type InvitePreview { + contact: String! + expiresAt: String! + inviterUsername: String + isValid: Boolean! + method: String! +} + +enum InviteStatus { + ACCEPTED + EXPIRED + PENDING + SENT +} + enum InvoicePaymentStatus { EXPIRED PAID @@ -1297,6 +1337,7 @@ type Mutation { callbackEndpointDelete(input: CallbackEndpointDeleteInput!): SuccessPayload! captchaCreateChallenge: CaptchaCreateChallengePayload! captchaRequestAuthCode(input: CaptchaRequestAuthCodeInput!): SuccessPayload! + createInvite(input: CreateInviteInput!): CreateInvitePayload! deviceNotificationTokenCreate(input: DeviceNotificationTokenCreateInput!): SuccessPayload! feedbackSubmit(input: FeedbackSubmitInput!): SuccessPayload! idDocumentUploadUrlGenerate(input: IdDocumentUploadUrlGenerateInput!): IdDocumentUploadUrlPayload! @@ -1405,6 +1446,7 @@ type Mutation { onChainUsdPaymentSend(input: OnChainUsdPaymentSendInput!): PaymentSendPayload! onChainUsdPaymentSendAsBtcDenominated(input: OnChainUsdPaymentSendAsBtcDenominatedInput!): PaymentSendPayload! quizCompleted(input: QuizCompletedInput!): QuizCompletedPayload! + redeemInvite(input: RedeemInviteInput!): RedeemInvitePayload! """ Returns an offer from Flash for a user to withdraw from their USD wallet (denominated in cents). @@ -1682,6 +1724,7 @@ type Query { cashWalletCutover: CashWalletCutover! currencyList: [Currency!]! globals: Globals + invitePreview(token: String!): InvitePreview isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload latestAccountUpgradeRequest: AccountUpgradeRequestPayload! lnInvoicePaymentStatus(input: LnInvoicePaymentStatusInput!): LnInvoicePaymentStatusPayload! @@ -1744,6 +1787,15 @@ type RealtimePricePayload { realtimePrice: RealtimePrice } +input RedeemInviteInput { + token: String! +} + +type RedeemInvitePayload { + errors: [String!]! + success: Boolean! +} + input RequestCashoutInput { """Amount in USD cents.""" amount: USDCents! diff --git a/src/graphql/public/schema/invite.graphql b/src/graphql/public/schema/invite.graphql new file mode 100644 index 000000000..9fcfded24 --- /dev/null +++ b/src/graphql/public/schema/invite.graphql @@ -0,0 +1,45 @@ +enum InviteMethod { + EMAIL + SMS + WHATSAPP +} + +enum InviteStatus { + PENDING + SENT + ACCEPTED + EXPIRED +} + +type Invite { + id: ID! + contact: String! + method: InviteMethod! + status: InviteStatus! + createdAt: Timestamp! + expiresAt: Timestamp! +} + +input CreateInviteInput { + contact: String! + method: InviteMethod! +} + +type CreateInvitePayload { + invite: Invite + errors: [Error!]! +} + +input RedeemInviteInput { + token: String! +} + +type RedeemInvitePayload { + success: Boolean! + errors: [Error!]! +} + +extend type Mutation { + createInvite(input: CreateInviteInput!): CreateInvitePayload! + redeemInvite(input: RedeemInviteInput!): RedeemInvitePayload! +} \ No newline at end of file diff --git a/src/graphql/shared/types/scalar/invite-method.ts b/src/graphql/shared/types/scalar/invite-method.ts new file mode 100644 index 000000000..284fc9933 --- /dev/null +++ b/src/graphql/shared/types/scalar/invite-method.ts @@ -0,0 +1,13 @@ +import { GT } from "@graphql/index" +import { InviteMethod as DomainInviteMethod } from "@services/mongoose/models/invite" + +const InviteMethod = GT.Enum({ + name: "InviteMethod", + values: { + EMAIL: { value: DomainInviteMethod.EMAIL }, + SMS: { value: DomainInviteMethod.SMS }, + WHATSAPP: { value: DomainInviteMethod.WHATSAPP }, + }, +}) + +export default InviteMethod diff --git a/src/graphql/shared/types/scalar/invite-status.ts b/src/graphql/shared/types/scalar/invite-status.ts new file mode 100644 index 000000000..89a9ec9ac --- /dev/null +++ b/src/graphql/shared/types/scalar/invite-status.ts @@ -0,0 +1,14 @@ +import { GT } from "@graphql/index" +import { InviteStatus as DomainInviteStatus } from "@services/mongoose/models/invite" + +const InviteStatus = GT.Enum({ + name: "InviteStatus", + values: { + PENDING: { value: DomainInviteStatus.PENDING }, + SENT: { value: DomainInviteStatus.SENT }, + ACCEPTED: { value: DomainInviteStatus.ACCEPTED }, + EXPIRED: { value: DomainInviteStatus.EXPIRED }, + }, +}) + +export default InviteStatus diff --git a/src/graphql/shared/types/scalar/timestamp.ts b/src/graphql/shared/types/scalar/timestamp.ts index b71794305..9f3bf53c0 100644 --- a/src/graphql/shared/types/scalar/timestamp.ts +++ b/src/graphql/shared/types/scalar/timestamp.ts @@ -4,6 +4,20 @@ import { GT } from "@graphql/index" type InternalDate = Date type ExternalDate = number | InputValidationError +// A pure-digit string is Unix seconds; anything else must be a parseable date +// string (e.g. ISO-8601). `parseInt("2026-07-30T…")` would silently yield 2026 +// (≈1970 as epoch seconds), which once corrupted an admin-supplied scheduledAt. +const parseDateString = (value: string): Date | InputValidationError => { + if (/^\d+$/.test(value)) { + return new Date(parseInt(value, 10) * 1000) // Unix seconds -> ms + } + const date = new Date(value) + if (isNaN(date.getTime())) { + return new InputValidationError({ message: "Invalid timestamp value" }) + } + return date +} + const Timestamp = GT.Scalar({ name: "Timestamp", description: @@ -18,14 +32,20 @@ const Timestamp = GT.Scalar({ return new InputValidationError({ message: "Invalid value for Date" }) }, parseValue(value) { - if (typeof value !== "string") { - return new InputValidationError({ message: "Invalid type for Date" }) + if (typeof value === "number") { + return new Date(value * 1000) // Unix seconds -> ms + } + if (typeof value === "string") { + return parseDateString(value) } - return new Date(value) + return new InputValidationError({ message: "Invalid type for Date" }) }, parseLiteral(ast) { + if (ast.kind === GT.Kind.INT) { + return new Date(parseInt(ast.value, 10) * 1000) // Unix seconds -> ms + } if (ast.kind === GT.Kind.STRING) { - return new Date(parseInt(ast.value, 10)) + return parseDateString(ast.value) } return new InputValidationError({ message: "Invalid type for Date" }) }, diff --git a/src/services/bridge/webhook-server/routes/kyc.ts b/src/services/bridge/webhook-server/routes/kyc.ts index c0d153d27..a5d4d46c0 100644 --- a/src/services/bridge/webhook-server/routes/kyc.ts +++ b/src/services/bridge/webhook-server/routes/kyc.ts @@ -24,6 +24,7 @@ import { toBridgeKycNotificationOutcome, } from "@app/bridge/send-kyc-notification" import { AccountsRepository } from "@services/mongoose/accounts" +import { awardReferralRewardOnKycApproval } from "@app/invite" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" import { toBridgeCustomerId } from "@domain/primitives/bridge" @@ -169,6 +170,12 @@ export const kycHandler = async (req: Request, res: Response) => { "Virtual account auto-created after KYC approval", ) } + + // If this account was referred, pay the tiered referral reward to both + // the inviter and this invitee. Reaching "approved" is once-only (CAS + // guard above), and the payout is itself idempotent + fail-closed, so it + // never throws into or blocks the KYC flow. + await awardReferralRewardOnKycApproval({ accountId: account.id }) } else if (nextStatus === "rejected") { baseLogger.warn( { accountId: account.id, customerId, rejectionReasons }, diff --git a/src/services/mongoose/accounts.ts b/src/services/mongoose/accounts.ts index 447fef1b5..779545639 100644 --- a/src/services/mongoose/accounts.ts +++ b/src/services/mongoose/accounts.ts @@ -12,6 +12,8 @@ import { UsdDisplayCurrency } from "@domain/fiat" import { Account } from "@services/mongoose/schema" +export { Account } + import { fromObjectId, parseRepositoryError, toObjectId } from "./utils" export const AccountsRepository = (): IAccountsRepository => { @@ -271,6 +273,18 @@ export const AccountsRepository = (): IAccountsRepository => { } } + // Resolve a singleton internal account by its role (e.g. "rewards", the + // funding account for referral reward payouts). + const findByRole = async (role: string): Promise => { + try { + const result = await Account.findOne({ role: { $eq: role } }) + if (!result) return new RepositoryError(`Account not found for role ${role}`) + return translateToAccount(result) + } catch (error) { + return parseRepositoryError(error) + } + } + return { persistNew, findByUserId, @@ -284,6 +298,7 @@ export const AccountsRepository = (): IAccountsRepository => { updateBridgeFields, findByBridgeEthereumAddress, findByBridgeCustomerId, + findByRole, } } diff --git a/src/services/mongoose/models/invite.ts b/src/services/mongoose/models/invite.ts new file mode 100644 index 000000000..823dd635b --- /dev/null +++ b/src/services/mongoose/models/invite.ts @@ -0,0 +1,132 @@ +import mongoose, { Schema } from "mongoose" + +export enum InviteMethod { + EMAIL = "EMAIL", + SMS = "SMS", + WHATSAPP = "WHATSAPP", +} + +export enum InviteStatus { + PENDING = "PENDING", + SENT = "SENT", + ACCEPTED = "ACCEPTED", + EXPIRED = "EXPIRED", +} + +export interface InviteRecord { + contact: string + method: InviteMethod + tokenHash: string + inviterId: mongoose.Types.ObjectId + status: InviteStatus + createdAt: Date + expiresAt: Date + redeemedAt?: Date + redeemedById?: mongoose.Types.ObjectId + revokedAt?: Date + revokeReason?: string + // Referral reward payout (deferred to the invitee's Bridge KYC approval). + // rewardStatus is the once-only claim guard: absent => unclaimed. + // "pending" = an IBEX send returned Pending; non-terminal, ops re-checks it. + rewardStatus?: "processing" | "paid" | "partial" | "failed" | "pending" + rewardSeq?: number // global sequence assigned at claim; determines the tier + rewardAmountCents?: number // per-party amount for this referral's tier + // Stamped when the claim is taken, so a sweep can find rows stuck in + // "processing" (e.g. process killed mid-payout before a terminal mark). + rewardClaimedAt?: Date + rewardedAt?: Date + inviterRewardedAt?: Date + inviteeRewardedAt?: Date + rewardError?: string +} + +const InviteSchema = new Schema({ + contact: { + type: String, + required: true, + index: true, + }, + method: { + type: String, + enum: Object.values(InviteMethod), + required: true, + }, + tokenHash: { + type: String, + required: true, + unique: true, + index: true, + }, + inviterId: { + type: Schema.Types.ObjectId, + ref: "Account", + required: true, + index: true, + }, + status: { + type: String, + enum: Object.values(InviteStatus), + default: InviteStatus.PENDING, + required: true, + }, + createdAt: { + type: Date, + default: Date.now, + }, + expiresAt: { + type: Date, + required: true, + index: true, + }, + redeemedAt: { + type: Date, + }, + redeemedById: { + type: Schema.Types.ObjectId, + ref: "Account", + }, + revokedAt: { + type: Date, + }, + revokeReason: { + type: String, + }, + rewardStatus: { + type: String, + enum: ["processing", "paid", "partial", "failed", "pending"], + }, + rewardSeq: { + type: Number, + }, + rewardAmountCents: { + type: Number, + }, + rewardClaimedAt: { + type: Date, + }, + rewardedAt: { + type: Date, + }, + inviterRewardedAt: { + type: Date, + }, + inviteeRewardedAt: { + type: Date, + }, + rewardError: { + type: String, + }, +}) + +InviteSchema.index({ inviterId: 1, createdAt: -1 }) +InviteSchema.index({ contact: 1, createdAt: -1 }) +InviteSchema.index({ status: 1, expiresAt: 1 }) +// One redeemed invite per account, ever — enforced at the storage layer so a +// concurrent double-redeem race can't slip past the application check. Partial: +// un-redeemed invites (no redeemedById) are unconstrained. +InviteSchema.index( + { redeemedById: 1 }, + { unique: true, partialFilterExpression: { redeemedById: { $exists: true } } }, +) + +export const InviteRepository = mongoose.model("Invite", InviteSchema) diff --git a/src/services/mongoose/models/referral-reward-counter.ts b/src/services/mongoose/models/referral-reward-counter.ts new file mode 100644 index 000000000..37bef680a --- /dev/null +++ b/src/services/mongoose/models/referral-reward-counter.ts @@ -0,0 +1,32 @@ +import mongoose, { Schema } from "mongoose" + +// A single-document, monotonically-increasing counter used to assign each paid +// referral a unique sequence number. The sequence drives the tiered reward +// amount (e.g. first 100 referrals pay more). An atomic `$inc` guarantees no two +// concurrent payouts get the same number, so tier boundaries are exact. +export interface ReferralRewardCounterRecord { + _id: string + seq: number +} + +const ReferralRewardCounterSchema = new Schema({ + _id: { type: String }, + seq: { type: Number, default: 0 }, +}) + +export const ReferralRewardCounter = mongoose.model( + "ReferralRewardCounter", + ReferralRewardCounterSchema, +) + +const COUNTER_ID = "referral_reward" + +// Atomically reserve and return the next referral sequence number (1-based). +export const nextReferralRewardSeq = async (): Promise => { + const doc = await ReferralRewardCounter.findOneAndUpdate( + { _id: COUNTER_ID }, + { $inc: { seq: 1 } }, + { upsert: true, new: true }, + ) + return doc.seq +} diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 2ac17b080..a23a2ad37 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -222,7 +222,7 @@ const AccountSchema = new Schema( // there can be many users and editors // there can be only one dealer, bankowner and funder // so we may want different property to differentiate those - enum: ["user", "editor", "dealer", "bankowner", "funder"], + enum: ["user", "editor", "dealer", "bankowner", "funder", "rewards"], required: true, default: "user", // TODO : enforce the fact there can be only one dealer/bankowner/funder diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts new file mode 100644 index 000000000..1ef4b1fcf --- /dev/null +++ b/src/services/notification/index.ts @@ -0,0 +1,255 @@ +import twilio from "twilio" +import sgMail from "@sendgrid/mail" +import { baseLogger } from "@services/logger" +import { env, SendGridConfig, TWILIO_FROM, TWILIO_WHATSAPP_FROM } from "@config" + +export enum NotificationMethod { + EMAIL = "EMAIL", + SMS = "SMS", + WHATSAPP = "WHATSAPP", +} + +export interface NotificationService { + sendNotification( + method: NotificationMethod, + to: string, + subjectOrBody: string, + htmlBody?: string, + ): Promise +} + +class NotificationServiceImpl implements NotificationService { + private twilioClient: twilio.Twilio | null = null + + constructor() { + this.initializeTwilio() + this.initializeSendGrid() + } + + private initializeTwilio() { + try { + if (env.TWILIO_ACCOUNT_SID && env.TWILIO_AUTH_TOKEN) { + // Never log credential material (not even a prefix/length). + baseLogger.info( + { + accountSid: env.TWILIO_ACCOUNT_SID, + verifyServiceId: env.TWILIO_VERIFY_SERVICE_ID, + twilioFrom: env.TWILIO_FROM || "NOT SET", + twilioWhatsAppFrom: env.TWILIO_WHATSAPP_FROM || "NOT SET", + }, + "Initializing Twilio client with credentials", + ) + + this.twilioClient = twilio(env.TWILIO_ACCOUNT_SID, env.TWILIO_AUTH_TOKEN) + baseLogger.info("Twilio client initialized successfully") + } else { + baseLogger.warn( + { + hasAccountSid: !!env.TWILIO_ACCOUNT_SID, + hasAuthToken: !!env.TWILIO_AUTH_TOKEN, + }, + "Twilio credentials not fully configured", + ) + } + } catch (error) { + baseLogger.error({ error }, "Failed to initialize Twilio client") + } + } + + private initializeSendGrid() { + try { + if (SendGridConfig?.apiKey) { + sgMail.setApiKey(SendGridConfig.apiKey) + baseLogger.info("SendGrid client initialized successfully") + } else { + baseLogger.warn("SendGrid API key not configured") + } + } catch (error) { + baseLogger.error({ error }, "Failed to initialize SendGrid client") + } + } + + async sendNotification( + method: NotificationMethod, + to: string, + subjectOrBody: string, + htmlBody?: string, + ): Promise { + try { + switch (method) { + case NotificationMethod.EMAIL: + return await this.sendEmail(to, subjectOrBody, htmlBody) + case NotificationMethod.SMS: + return await this.sendSMS(to, subjectOrBody) + case NotificationMethod.WHATSAPP: + return await this.sendWhatsApp(to, subjectOrBody) + default: + baseLogger.error({ method }, "Unknown notification method") + return false + } + } catch (error) { + baseLogger.error({ error, method, to }, "Failed to send notification") + return false + } + } + + private async sendEmail( + to: string, + subject: string, + htmlBody?: string, + ): Promise { + if (!SendGridConfig?.apiKey) { + baseLogger.error("SendGrid API key not configured") + return false + } + + const fromEmail = process.env.SENDGRID_FROM_EMAIL || "noreply@getflash.io" + + try { + await sgMail.send({ + to, + from: fromEmail, + subject, + text: subject, + html: htmlBody || subject, + }) + + baseLogger.info({ to }, "Email sent successfully via SendGrid") + return true + } catch (error) { + baseLogger.error({ error, to }, "Failed to send email via SendGrid") + return false + } + } + + private async sendSMS(to: string, body: string): Promise { + if (!this.twilioClient) { + baseLogger.error("Twilio client not configured") + return false + } + + if (!TWILIO_FROM) { + baseLogger.error("TWILIO_FROM not configured") + return false + } + + try { + await this.twilioClient.messages.create({ + body, + from: TWILIO_FROM, + to, + }) + baseLogger.info({ to }, "SMS sent successfully via Twilio") + return true + } catch (error) { + baseLogger.error({ error, to }, "Failed to send SMS") + return false + } + } + + private async sendWhatsApp(to: string, body: string): Promise { + if (!this.twilioClient) { + baseLogger.error("Twilio client not configured") + return false + } + + if (!TWILIO_WHATSAPP_FROM) { + baseLogger.error("TWILIO_WHATSAPP_FROM not configured") + return false + } + + const whatsappTo = to.startsWith("whatsapp:") ? to : `whatsapp:${to}` + const whatsappFrom = TWILIO_WHATSAPP_FROM.startsWith("whatsapp:") + ? TWILIO_WHATSAPP_FROM + : `whatsapp:${TWILIO_WHATSAPP_FROM}` + + baseLogger.info( + { + to: whatsappTo, + from: whatsappFrom, + bodyLength: body.length, + accountSid: env.TWILIO_ACCOUNT_SID, + hasAuthToken: !!env.TWILIO_AUTH_TOKEN, + }, + "Attempting to send WhatsApp message", + ) + + try { + // Check if body contains template information + const messageOptions: { + from: string + to: string + body?: string + contentSid?: string + contentVariables?: string + } = { + from: whatsappFrom, + to: whatsappTo, + } + + try { + const templateData = JSON.parse(body) + if (templateData.templateName && templateData.templateVariables) { + // Use WhatsApp template + messageOptions.contentSid = process.env.TWILIO_WHATSAPP_TEMPLATE_SID || "" + messageOptions.contentVariables = JSON.stringify(templateData.templateVariables) + } else { + // Regular message (for sandbox/testing) + messageOptions.body = body + } + } catch { + // Not JSON, use as regular message body + messageOptions.body = body + } + + // Redacted: body/contentVariables can carry a raw invite token (tokens + // are stored only as sha256 hashes — they must never reach the logs). + baseLogger.info( + { + from: messageOptions.from, + to: messageOptions.to, + usesTemplate: Boolean(messageOptions.contentSid), + }, + "Sending WhatsApp message with options", + ) + + const message = await this.twilioClient.messages.create(messageOptions) + + baseLogger.info( + { + to: whatsappTo, + messageSid: message.sid, + status: message.status, + }, + "WhatsApp message sent successfully via Twilio", + ) + return true + } catch (error) { + const err = error as { + message?: string + code?: string + status?: number + moreInfo?: string + details?: string + } + baseLogger.error( + { + error: { + message: err.message, + code: err.code, + status: err.status, + moreInfo: err.moreInfo, + details: err.details, + }, + to: whatsappTo, + from: whatsappFrom, + accountSid: env.TWILIO_ACCOUNT_SID, + }, + "Failed to send WhatsApp message", + ) + return false + } + } +} + +export const notificationService = new NotificationServiceImpl() diff --git a/src/services/notifications/invite.ts b/src/services/notifications/invite.ts new file mode 100644 index 000000000..cbcb12a74 --- /dev/null +++ b/src/services/notifications/invite.ts @@ -0,0 +1,107 @@ +import { InviteMethod } from "@domain/invite" +import { notificationService, NotificationMethod } from "@services/notification" +import { baseLogger } from "@services/logger" + +const buildInviteLink = (token: string): string => { + const firebaseDomain = process.env.FIREBASE_DYNAMIC_LINK_DOMAIN + const appInstallUrl = process.env.APP_INSTALL_URL || "https://getflash.io/app" + const androidPackage = process.env.ANDROID_PACKAGE_NAME || "com.lnflash" + const iosBundleId = process.env.IOS_BUNDLE_ID || "com.lnflash" + + if (firebaseDomain) { + const params = new URLSearchParams({ + link: `${appInstallUrl}?token=${token}`, + apn: androidPackage, + ibi: iosBundleId, + st: "Flash App Invite", + sd: "You've been invited to join Flash App", + ofl: `https://getflash.io/invite?token=${token}`, + }) + return `https://${firebaseDomain}/?${params.toString()}` + } + + return `https://getflash.io/invite?token=${token}` +} + +export const sendInviteNotification = async ({ + method, + contact, + token, + senderName, +}: { + method: InviteMethod + contact: string + token: string + senderName: string +}): Promise => { + try { + const inviteLink = buildInviteLink(token) + + // Convert InviteMethod to NotificationMethod + const notificationMethod = method as unknown as NotificationMethod + + let messageBody: string + let htmlBody: string | undefined + + switch (method) { + case InviteMethod.EMAIL: + messageBody = `${senderName} invited you to Flash` + htmlBody = ` + + +

You're Invited to Flash!

+

${senderName} has invited you to join Flash, your all-in-one wallet for fast, secure payments and rewards.

+

Click the link below to get started:

+ Accept Invite +

Or copy this link: ${inviteLink}

+

This invitation expires in 24 hours.

+ + + ` + break + + case InviteMethod.WHATSAPP: + // For WhatsApp templates (if using approved templates) + messageBody = JSON.stringify({ + templateName: "flash_invite", + templateVariables: { + "1": senderName, + "2": token, + }, + }) + break + + case InviteMethod.SMS: + default: + messageBody = `${senderName} invited you to Flash! Join using this link: ${inviteLink}` + break + } + + const success = await notificationService.sendNotification( + notificationMethod, + contact, + messageBody, + htmlBody, + ) + + if (success) { + baseLogger.info( + { method, contact, senderName }, + "Invite notification sent successfully", + ) + } else { + baseLogger.error( + { method, contact, senderName }, + "Failed to send invite notification", + ) + } + + return success + } catch (error) { + baseLogger.error( + { error, method, contact, senderName }, + "Error sending invite notification", + ) + return false + } +} diff --git a/src/utils/hash.ts b/src/utils/hash.ts new file mode 100644 index 000000000..3a4a4169b --- /dev/null +++ b/src/utils/hash.ts @@ -0,0 +1,19 @@ +import { createHash, randomBytes } from "crypto" + +export const sha256 = (data: string): string => { + return createHash("sha256").update(data).digest("hex") +} + +export const generateSecureToken = (bytes: number = 20): string => { + return randomBytes(bytes).toString("hex") +} + +export const hashToken = (token: string): string => { + return sha256(token) +} + +export const generateInviteToken = (): { token: string; tokenHash: string } => { + const token = generateSecureToken(20) + const tokenHash = hashToken(token) + return { token, tokenHash } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 80447b354..c71083463 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,7 @@ import { NonIntegerError } from "@domain/errors" import { decode as decodeBolt11 } from "bolt11" export * as GrpcStreamClient from "./grpc-stream-client" +export * from "./hash" export async function sleep(ms: MilliSeconds | number) { return new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/test/flash/unit/app/admin/invite.spec.ts b/test/flash/unit/app/admin/invite.spec.ts new file mode 100644 index 000000000..2c8607786 --- /dev/null +++ b/test/flash/unit/app/admin/invite.spec.ts @@ -0,0 +1,142 @@ +import { CouldNotFindError } from "@domain/errors" +import { InviteAlreadyAcceptedError, InvalidExpirationDateError } from "@domain/invite" + +const mockFindById = jest.fn() +jest.mock("@services/mongoose/models/invite", () => { + const actual = jest.requireActual("@services/mongoose/models/invite") + return { + InviteMethod: actual.InviteMethod, + InviteStatus: actual.InviteStatus, + InviteRepository: { findById: (...args: unknown[]) => mockFindById(...args) }, + } +}) + +const mockRedis = { + del: jest.fn(), + get: jest.fn(), + ttl: jest.fn(), + scan: jest.fn(), +} +jest.mock("@services/redis", () => ({ + redis: { + del: (...args: unknown[]) => mockRedis.del(...args), + get: (...args: unknown[]) => mockRedis.get(...args), + ttl: (...args: unknown[]) => mockRedis.ttl(...args), + scan: (...args: unknown[]) => mockRedis.scan(...args), + }, +})) + +import { + revokeInvite, + extendInvite, + resetInviteRateLimit, + getInviteRateLimitStatus, +} from "@app/admin/invite" +import { InviteStatus } from "@services/mongoose/models/invite" + +const INVITER = "507f1f77bcf86cd799439011" + +const baseInvite = (overrides: Record = {}) => ({ + _id: { toString: () => "invite-1" }, + contact: "friend@example.com", + method: "EMAIL", + status: InviteStatus.SENT, + inviterId: { toString: () => INVITER }, + createdAt: new Date(), + expiresAt: new Date(), + revokedAt: undefined as Date | undefined, + revokeReason: undefined as string | undefined, + save: jest.fn(), + ...overrides, +}) + +describe("admin revokeInvite", () => { + beforeEach(() => jest.clearAllMocks()) + + it("returns CouldNotFindError when missing", async () => { + mockFindById.mockResolvedValue(null) + expect(await revokeInvite("id" as never)).toBeInstanceOf(CouldNotFindError) + }) + + it("refuses to revoke an accepted invite", async () => { + mockFindById.mockResolvedValue(baseInvite({ status: InviteStatus.ACCEPTED })) + expect(await revokeInvite("id" as never)).toBeInstanceOf(InviteAlreadyAcceptedError) + }) + + it("marks the invite EXPIRED with a reason", async () => { + const invite = baseInvite() + mockFindById.mockResolvedValue(invite) + + const result = await revokeInvite("id" as never, "spam") + + expect(invite.status).toBe(InviteStatus.EXPIRED) + expect(invite.revokedAt).toBeInstanceOf(Date) + expect(invite.revokeReason).toBe("spam") + expect(invite.save).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ id: "invite-1", status: InviteStatus.EXPIRED }) + }) +}) + +describe("admin extendInvite", () => { + beforeEach(() => jest.clearAllMocks()) + + it("rejects a non-future expiration", async () => { + mockFindById.mockResolvedValue(baseInvite()) + const past = new Date(Date.now() - 1000) + expect(await extendInvite("id" as never, past)).toBeInstanceOf( + InvalidExpirationDateError, + ) + }) + + it("refuses to extend an accepted invite", async () => { + mockFindById.mockResolvedValue(baseInvite({ status: InviteStatus.ACCEPTED })) + const future = new Date(Date.now() + 86_400_000) + expect(await extendInvite("id" as never, future)).toBeInstanceOf( + InviteAlreadyAcceptedError, + ) + }) + + it("extends and resets the invite to PENDING", async () => { + const invite = baseInvite({ status: InviteStatus.EXPIRED }) + mockFindById.mockResolvedValue(invite) + const future = new Date(Date.now() + 86_400_000) + + const result = await extendInvite("id" as never, future) + + expect(invite.expiresAt).toBe(future) + expect(invite.status).toBe(InviteStatus.PENDING) + expect(invite.save).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ status: InviteStatus.PENDING }) + }) +}) + +describe("admin rate-limit helpers", () => { + beforeEach(() => jest.clearAllMocks()) + + it("resetInviteRateLimit deletes the account key", async () => { + mockRedis.del.mockResolvedValue(1) + const result = await resetInviteRateLimit(INVITER as AccountId) + expect(result).toBe(true) + expect(mockRedis.del).toHaveBeenCalledTimes(1) + expect(mockRedis.del.mock.calls[0][0]).toContain(INVITER) + }) + + it("getInviteRateLimitStatus reports counts and configured limits", async () => { + mockRedis.get.mockResolvedValue("4") + mockRedis.ttl.mockResolvedValue(120) + + const result = await getInviteRateLimitStatus({ + accountId: INVITER as AccountId, + contact: "+12025550123", + }) + + expect(result).toMatchObject({ + dailyCount: 4, + dailyLimit: 10, + targetCount: 4, + targetLimit: 3, + dailyTtl: 120, + targetTtl: 120, + }) + }) +}) diff --git a/test/flash/unit/app/authentication/ops-events-hooks.spec.ts b/test/flash/unit/app/authentication/ops-events-hooks.spec.ts index b22788d5f..8bb39d1b8 100644 --- a/test/flash/unit/app/authentication/ops-events-hooks.spec.ts +++ b/test/flash/unit/app/authentication/ops-events-hooks.spec.ts @@ -29,6 +29,8 @@ jest.mock("@config", () => { getFailedLoginAttemptPerLoginIdentifierLimits: jest.fn(() => limits), getInvoiceCreateAttemptLimits: jest.fn(() => limits), getInvoiceCreateForRecipientAttemptLimits: jest.fn(() => limits), + getInviteCreateAttemptLimits: jest.fn(() => limits), + getInviteTargetAttemptLimits: jest.fn(() => limits), getOnChainAddressCreateAttemptLimits: jest.fn(() => limits), getRequestCodePerIpLimits: jest.fn(() => limits), getRequestCodePerLoginIdentifierLimits: jest.fn(() => limits), diff --git a/test/flash/unit/app/invite/award-referral-reward.spec.ts b/test/flash/unit/app/invite/award-referral-reward.spec.ts new file mode 100644 index 000000000..fd1cb7640 --- /dev/null +++ b/test/flash/unit/app/invite/award-referral-reward.spec.ts @@ -0,0 +1,339 @@ +import { PaymentSendStatus } from "@domain/bitcoin/lightning" + +const mockGetConfig = jest.fn() +jest.mock("@config", () => ({ + ...jest.requireActual("@config"), + getReferralRewardConfig: (...args: unknown[]) => mockGetConfig(...args), +})) + +const mockFindOne = jest.fn() +const mockFindOneAndUpdate = jest.fn() +const mockUpdateOne = jest.fn() +const mockExists = jest.fn() +jest.mock("@services/mongoose/models/invite", () => { + const actual = jest.requireActual("@services/mongoose/models/invite") + return { + InviteMethod: actual.InviteMethod, + InviteStatus: actual.InviteStatus, + InviteRepository: { + findOne: (...a: unknown[]) => mockFindOne(...a), + findOneAndUpdate: (...a: unknown[]) => mockFindOneAndUpdate(...a), + updateOne: (...a: unknown[]) => mockUpdateOne(...a), + exists: (...a: unknown[]) => mockExists(...a), + }, + } +}) + +const mockNextSeq = jest.fn() +jest.mock("@services/mongoose/models/referral-reward-counter", () => ({ + nextReferralRewardSeq: (...a: unknown[]) => mockNextSeq(...a), +})) + +const mockFindByRole = jest.fn() +const mockListByAccountId = jest.fn() +jest.mock("@services/mongoose", () => ({ + AccountsRepository: () => ({ findByRole: (...a: unknown[]) => mockFindByRole(...a) }), + WalletsRepository: () => ({ + listByAccountId: (...a: unknown[]) => mockListByAccountId(...a), + }), +})) + +const mockPay = jest.fn() +jest.mock("@app/payments/send-intraledger", () => ({ + intraledgerPaymentSendWalletIdForUsdWallet: (...a: unknown[]) => mockPay(...a), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})) + +import { awardReferralRewardOnKycApproval } from "@app/invite/award-referral-reward" +import { baseLogger } from "@services/logger" + +const INVITEE = "invitee-account-id" as AccountId +const INVITER = "inviter-account-id" +const REWARDS_ACCT = "rewards-account-id" + +const DEFAULT_TIERS = [ + { upToCount: 100, amountCents: 500 }, + { upToCount: 600, amountCents: 250 }, + { upToCount: 0, amountCents: 100 }, +] + +const pendingInvite = () => ({ + _id: "invite-1", + inviterId: { toString: () => INVITER }, + redeemedById: { toString: () => INVITEE }, +}) + +const usd = (id: string) => ({ currency: "USD", id }) +const usdt = (id: string) => ({ currency: "USDT", id }) +const btc = (id: string) => ({ currency: "BTC", id }) + +// Route listByAccountId(accountId) -> wallets, by account. +const walletsBy = (map: Record) => (accountId: string) => + map[accountId] ?? [] + +const useWallets = (map: Record) => + mockListByAccountId.mockImplementation((accountId: string) => + Promise.resolve(walletsBy(map)(accountId)), + ) + +const lastSet = () => + mockUpdateOne.mock.calls[mockUpdateOne.mock.calls.length - 1][1].$set + +describe("awardReferralRewardOnKycApproval", () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetConfig.mockReturnValue({ enabled: true, tiers: DEFAULT_TIERS }) + mockExists.mockResolvedValue(null) // no prior processed invite for the account + mockFindOne.mockResolvedValue(pendingInvite()) + mockFindOneAndUpdate.mockResolvedValue(pendingInvite()) + mockNextSeq.mockResolvedValue(50) + mockFindByRole.mockResolvedValue({ id: REWARDS_ACCT }) + // Every account holds both wallets; USDT is the active cash wallet. + useWallets({ + [REWARDS_ACCT]: [usd("rewards-usd"), usdt("rewards-usdt")], + [INVITER]: [usd("inviter-usd"), usdt("inviter-usdt")], + [INVITEE]: [usd("invitee-usd"), usdt("invitee-usdt")], + }) + mockPay.mockResolvedValue(PaymentSendStatus.Success) + }) + + it("no-ops when the feature is disabled", async () => { + mockGetConfig.mockReturnValue({ enabled: false, tiers: DEFAULT_TIERS }) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockFindOne).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + }) + + it("never pays a second reward for the same account (KYC re-approval flap)", async () => { + // A prior invite for this account was already claimed/processed. + mockExists.mockResolvedValue({ _id: "earlier-invite" }) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockExists).toHaveBeenCalledWith({ + redeemedById: INVITEE, + rewardStatus: { $exists: true }, + }) + expect(mockFindOne).not.toHaveBeenCalled() + expect(mockFindOneAndUpdate).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + }) + + it("no-ops when the account redeemed no (unclaimed) invite", async () => { + mockFindOne.mockResolvedValue(null) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockFindOneAndUpdate).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + }) + + it("no-ops when the atomic claim is lost to a concurrent caller", async () => { + mockFindOneAndUpdate.mockResolvedValue(null) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockNextSeq).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + }) + + it("claims atomically, stamping processing + rewardClaimedAt", async () => { + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + const [filter, update] = mockFindOneAndUpdate.mock.calls[0] + expect(filter).toMatchObject({ rewardStatus: { $exists: false } }) + expect(update).toEqual({ + $set: { rewardStatus: "processing", rewardClaimedAt: expect.any(Date) }, + }) + }) + + it("pays both parties from the USDT wallet (active cash wallet) and marks paid", async () => { + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockPay).toHaveBeenCalledTimes(2) + expect(mockPay).toHaveBeenCalledWith( + expect.objectContaining({ + senderWalletId: "rewards-usdt", + recipientWalletId: "inviter-usdt", + amount: 500, + }), + ) + expect(mockPay).toHaveBeenCalledWith( + expect.objectContaining({ + senderWalletId: "rewards-usdt", + recipientWalletId: "invitee-usdt", + amount: 500, + }), + ) + + const set = lastSet() + expect(set.rewardStatus).toBe("paid") + expect(set.rewardSeq).toBe(50) + expect(set.rewardAmountCents).toBe(500) + expect(set.rewardedAt).toBeInstanceOf(Date) + expect(set.inviterRewardedAt).toBeInstanceOf(Date) + expect(set.inviteeRewardedAt).toBeInstanceOf(Date) + }) + + it("falls back to USD when the rewards account has no USDT wallet, matching recipients by USD", async () => { + useWallets({ + [REWARDS_ACCT]: [usd("rewards-usd")], + [INVITER]: [usd("inviter-usd"), usdt("inviter-usdt")], + [INVITEE]: [usd("invitee-usd")], + }) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockPay).toHaveBeenCalledWith( + expect.objectContaining({ + senderWalletId: "rewards-usd", + recipientWalletId: "inviter-usd", // matched by sender currency, not USDT + }), + ) + expect(mockPay).toHaveBeenCalledWith( + expect.objectContaining({ + senderWalletId: "rewards-usd", + recipientWalletId: "invitee-usd", + }), + ) + expect(lastSet().rewardStatus).toBe("paid") + }) + + it("applies the tier for the assigned sequence (101 -> 250)", async () => { + mockNextSeq.mockResolvedValue(101) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockPay).toHaveBeenCalledWith(expect.objectContaining({ amount: 250 })) + expect(lastSet().rewardAmountCents).toBe(250) + }) + + it("marks paid without paying when the amount is zero", async () => { + mockGetConfig.mockReturnValue({ enabled: true, tiers: [] }) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockFindByRole).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + const set = lastSet() + expect(set.rewardStatus).toBe("paid") + expect(set.rewardAmountCents).toBe(0) + }) + + it("fails (no payout) when no account holds the rewards role", async () => { + mockFindByRole.mockResolvedValue(new Error("not found")) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockPay).not.toHaveBeenCalled() + expect(lastSet().rewardStatus).toBe("failed") + }) + + it("fails (no payout) when the rewards account has neither a USDT nor a USD wallet", async () => { + useWallets({ [REWARDS_ACCT]: [btc("rewards-btc")] }) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockPay).not.toHaveBeenCalled() + const set = lastSet() + expect(set.rewardStatus).toBe("failed") + expect(set.rewardError).toContain("USDT or USD") + }) + + it("records 'partial' when a recipient lacks a wallet in the payout currency", async () => { + useWallets({ + [REWARDS_ACCT]: [usdt("rewards-usdt")], + [INVITER]: [usd("inviter-usd")], // no USDT wallet -> can't receive + [INVITEE]: [usdt("invitee-usdt")], + }) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + expect(mockPay).toHaveBeenCalledTimes(1) + expect(mockPay).toHaveBeenCalledWith( + expect.objectContaining({ recipientWalletId: "invitee-usdt" }), + ) + const set = lastSet() + expect(set.rewardStatus).toBe("partial") + expect(set.inviteeRewardedAt).toBeInstanceOf(Date) + expect(set.inviterRewardedAt).toBeUndefined() + expect(set.rewardError).toContain("inviter=failed") + }) + + it("records 'partial' when one payout errors and the other succeeds", async () => { + mockPay + .mockResolvedValueOnce(new Error("ibex down")) // inviter + .mockResolvedValueOnce(PaymentSendStatus.Success) // invitee + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + const set = lastSet() + expect(set.rewardStatus).toBe("partial") + expect(set.inviterRewardedAt).toBeUndefined() + expect(set.inviteeRewardedAt).toBeInstanceOf(Date) + }) + + it("records 'failed' when both payouts error, without throwing", async () => { + mockPay.mockResolvedValue(new Error("ibex down")) + await expect( + awardReferralRewardOnKycApproval({ accountId: INVITEE }), + ).resolves.toBeUndefined() + expect(lastSet().rewardStatus).toBe("failed") + }) + + it("records 'pending' (non-terminal) when a payout is IBEX-pending, timestamps set", async () => { + mockPay.mockResolvedValue(PaymentSendStatus.Pending) + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + const set = lastSet() + expect(set.rewardStatus).toBe("pending") + // Fail-closed: pending parties are timestamped so a re-run can't double-pay. + expect(set.inviterRewardedAt).toBeInstanceOf(Date) + expect(set.inviteeRewardedAt).toBeInstanceOf(Date) + expect(set.rewardedAt).toBeUndefined() + expect(set.rewardError).toContain("inviter=pending") + expect(set.rewardError).toContain("invitee=pending") + }) + + it("records 'pending' when one party is paid and the other IBEX-pending", async () => { + mockPay + .mockResolvedValueOnce(PaymentSendStatus.Success) // inviter + .mockResolvedValueOnce(PaymentSendStatus.Pending) // invitee + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + const set = lastSet() + expect(set.rewardStatus).toBe("pending") + expect(set.inviterRewardedAt).toBeInstanceOf(Date) + expect(set.inviteeRewardedAt).toBeInstanceOf(Date) + }) + + it("records 'partial' when one party is IBEX-pending and the other fails", async () => { + mockPay + .mockResolvedValueOnce(PaymentSendStatus.Pending) // inviter + .mockResolvedValueOnce(new Error("ibex down")) // invitee + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + const set = lastSet() + expect(set.rewardStatus).toBe("partial") + expect(set.inviterRewardedAt).toBeInstanceOf(Date) // pending party stays timestamped + expect(set.inviteeRewardedAt).toBeUndefined() + }) + + it("downgrades a claimed invite to 'failed' when an unexpected error follows the claim", async () => { + mockNextSeq.mockRejectedValue(new Error("mongo hiccup")) + await expect( + awardReferralRewardOnKycApproval({ accountId: INVITEE }), + ).resolves.toBeUndefined() + const set = lastSet() + expect(set.rewardStatus).toBe("failed") + expect(set.rewardError).toContain("unexpected") + expect(set.rewardSeq).toBeUndefined() // seq was never assigned + expect(baseLogger.error).toHaveBeenCalled() + }) + + it("preserves paid-party evidence when the second payout leg throws", async () => { + mockPay + .mockResolvedValueOnce(PaymentSendStatus.Success) // inviter paid + .mockRejectedValueOnce(new Error("ibex client exploded")) // invitee leg THROWS + await expect( + awardReferralRewardOnKycApproval({ accountId: INVITEE }), + ).resolves.toBeUndefined() + const set = lastSet() + // The inviter's payment already went out: reconciliation must see it so a + // manual re-run can't double-pay them. + expect(set.rewardStatus).toBe("partial") + expect(set.inviterRewardedAt).toBeInstanceOf(Date) + expect(set.inviteeRewardedAt).toBeUndefined() + expect(set.rewardError).toContain("unexpected") + expect(set.rewardError).toContain("inviter=paid") + expect(set.rewardError).toContain("invitee=failed") + expect(set.rewardSeq).toBe(50) + }) + + it("never throws into the KYC path on an unexpected error", async () => { + mockFindOne.mockRejectedValue(new Error("mongo exploded")) + await expect( + awardReferralRewardOnKycApproval({ accountId: INVITEE }), + ).resolves.toBeUndefined() + expect(baseLogger.error).toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/invite/create-invite.spec.ts b/test/flash/unit/app/invite/create-invite.spec.ts new file mode 100644 index 000000000..82ba9e319 --- /dev/null +++ b/test/flash/unit/app/invite/create-invite.spec.ts @@ -0,0 +1,217 @@ +import { ValidationError } from "@domain/shared" + +jest.mock("@services/mongoose/models/invite", () => { + const actual = jest.requireActual("@services/mongoose/models/invite") + const save = jest.fn() + const findOne = jest.fn() + const deleteOne = jest.fn() + const Repo: jest.Mock & Record = jest + .fn() + .mockImplementation((data: Record) => ({ + ...data, + _id: { toString: () => "new-invite-id" }, + save, + })) as jest.Mock & Record + Repo.findOne = findOne + Repo.deleteOne = deleteOne + Repo.__save = save + Repo.__deleteOne = deleteOne + return { + InviteMethod: actual.InviteMethod, + InviteStatus: actual.InviteStatus, + InviteRepository: Repo, + } +}) + +const mockAccountFindById = jest.fn() +jest.mock("@services/mongoose", () => ({ + AccountsRepository: () => ({ findById: mockAccountFindById }), +})) + +const mockSendInviteNotification = jest.fn() +jest.mock("@services/notifications/invite", () => ({ + sendInviteNotification: (args: unknown) => mockSendInviteNotification(args), +})) + +const mockCreateRateLimit = jest.fn() +const mockTargetRateLimit = jest.fn() +jest.mock("@app/invite/rate-limits", () => ({ + checkInviteCreateRateLimit: () => mockCreateRateLimit(), + checkInviteTargetRateLimit: () => mockTargetRateLimit(), +})) + +jest.mock("@utils", () => ({ + generateInviteToken: () => ({ token: "t".repeat(40), tokenHash: "token-hash" }), +})) + +import { createInvite } from "@app/invite" +import { + InviteMethod, + InviteStatus, + InviteRepository, +} from "@services/mongoose/models/invite" + +const inviteRepo = InviteRepository as unknown as jest.Mock & { + findOne: jest.Mock + __save: jest.Mock + __deleteOne: jest.Mock +} +const mockFindOne = inviteRepo.findOne +const mockSave = inviteRepo.__save +const mockDeleteOne = inviteRepo.__deleteOne + +const ACCOUNT_ID = "507f1f77bcf86cd799439011" as AccountId +const EMAIL = "friend@example.com" + +const okLimits = () => { + mockCreateRateLimit.mockResolvedValue(true) + mockTargetRateLimit.mockResolvedValue(true) +} + +describe("createInvite", () => { + beforeEach(() => { + jest.clearAllMocks() + mockSendInviteNotification.mockResolvedValue(true) + }) + + it("rejects an invalid contact before checking limits", async () => { + const result = await createInvite({ + accountId: ACCOUNT_ID, + contact: "not-an-email", + method: InviteMethod.EMAIL, + }) + expect(result).toBeInstanceOf(ValidationError) + expect(mockCreateRateLimit).not.toHaveBeenCalled() + }) + + it("rejects when the daily create limit is exceeded", async () => { + mockCreateRateLimit.mockResolvedValue(new Error("limited")) + const result = await createInvite({ + accountId: ACCOUNT_ID, + contact: EMAIL, + method: InviteMethod.EMAIL, + }) + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe("Daily invite limit exceeded") + }) + + it("rejects when the per-contact target limit is exceeded", async () => { + mockCreateRateLimit.mockResolvedValue(true) + mockTargetRateLimit.mockResolvedValue(new Error("limited")) + const result = await createInvite({ + accountId: ACCOUNT_ID, + contact: EMAIL, + method: InviteMethod.EMAIL, + }) + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe( + "This contact has already been invited by multiple users", + ) + }) + + it("rejects a duplicate pending/sent invite from the same inviter", async () => { + okLimits() + mockFindOne.mockResolvedValue({ _id: "existing" }) + const result = await createInvite({ + accountId: ACCOUNT_ID, + contact: EMAIL, + method: InviteMethod.EMAIL, + }) + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe( + "This contact has already been invited", + ) + expect(mockSave).not.toHaveBeenCalled() + }) + + it("returns the account lookup error when the inviter is not found", async () => { + okLimits() + mockFindOne.mockResolvedValue(null) + const notFound = new Error("account gone") + mockAccountFindById.mockResolvedValue(notFound) + const result = await createInvite({ + accountId: ACCOUNT_ID, + contact: EMAIL, + method: InviteMethod.EMAIL, + }) + expect(result).toBe(notFound) + }) + + it("creates, notifies, and marks the invite as SENT on success", async () => { + okLimits() + mockFindOne.mockResolvedValue(null) + mockAccountFindById.mockResolvedValue({ username: "alice" }) + + const result = await createInvite({ + accountId: ACCOUNT_ID, + contact: EMAIL, + method: InviteMethod.EMAIL, + }) + + expect(result).not.toBeInstanceOf(Error) + expect(result).toMatchObject({ + id: "new-invite-id", + contact: EMAIL, + method: InviteMethod.EMAIL, + status: InviteStatus.SENT, + }) + // constructed with the hashed token, PENDING first + expect(inviteRepo).toHaveBeenCalledWith( + expect.objectContaining({ + contact: EMAIL, + tokenHash: "token-hash", + status: InviteStatus.PENDING, + }), + ) + // notification carries the inviter's username and the raw token + expect(mockSendInviteNotification).toHaveBeenCalledWith( + expect.objectContaining({ + contact: EMAIL, + senderName: "alice", + token: "t".repeat(40), + }), + ) + // saved twice: once PENDING, once SENT + expect(mockSave).toHaveBeenCalledTimes(2) + }) + + it("deletes the invite and returns an error when the notification fails to send", async () => { + okLimits() + mockFindOne.mockResolvedValue(null) + mockAccountFindById.mockResolvedValue({ username: "alice" }) + mockSendInviteNotification.mockResolvedValue(false) + + const result = await createInvite({ + accountId: ACCOUNT_ID, + contact: EMAIL, + method: InviteMethod.EMAIL, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe( + "Failed to send invitation — please try again", + ) + // The doc is removed so the PENDING/SENT duplicate check can't block a retry. + expect(mockDeleteOne).toHaveBeenCalledWith({ + _id: expect.objectContaining({ toString: expect.any(Function) }), + }) + // Never marked SENT: only the initial PENDING save happened. + expect(mockSave).toHaveBeenCalledTimes(1) + }) + + it("falls back to 'A friend' when the inviter has no username", async () => { + okLimits() + mockFindOne.mockResolvedValue(null) + mockAccountFindById.mockResolvedValue({ username: null }) + + await createInvite({ + accountId: ACCOUNT_ID, + contact: EMAIL, + method: InviteMethod.EMAIL, + }) + + expect(mockSendInviteNotification).toHaveBeenCalledWith( + expect.objectContaining({ senderName: "A friend" }), + ) + }) +}) diff --git a/test/flash/unit/app/invite/queries.spec.ts b/test/flash/unit/app/invite/queries.spec.ts new file mode 100644 index 000000000..0a532f4ec --- /dev/null +++ b/test/flash/unit/app/invite/queries.spec.ts @@ -0,0 +1,133 @@ +import { CouldNotFindError } from "@domain/errors" + +const mockFindById = jest.fn() +const mockAggregate = jest.fn() +jest.mock("@services/mongoose/models/invite", () => { + const actual = jest.requireActual("@services/mongoose/models/invite") + return { + InviteMethod: actual.InviteMethod, + InviteStatus: actual.InviteStatus, + InviteRepository: { + findById: (...args: unknown[]) => mockFindById(...args), + aggregate: (...args: unknown[]) => mockAggregate(...args), + }, + } +}) + +const mockAccountFindById = jest.fn() +jest.mock("@services/mongoose", () => ({ + AccountsRepository: () => ({ findById: mockAccountFindById }), +})) + +import { getInviteById, listInvites } from "@app/invite/queries" +import { InviteStatus, InviteMethod } from "@services/mongoose/models/invite" + +const INVITER = "507f1f77bcf86cd799439011" +const REDEEMER = "507f1f77bcf86cd799439022" + +describe("getInviteById", () => { + beforeEach(() => jest.clearAllMocks()) + + it("returns CouldNotFindError when the invite is missing", async () => { + mockFindById.mockResolvedValue(null) + const result = await getInviteById("507f1f77bcf86cd799439011" as never) + expect(result).toBeInstanceOf(CouldNotFindError) + }) + + it("returns the invite with the inviter's username for a pending invite", async () => { + mockFindById.mockResolvedValue({ + _id: { toString: () => "invite-1" }, + contact: "friend@example.com", + method: InviteMethod.EMAIL, + status: InviteStatus.SENT, + inviterId: { toString: () => INVITER }, + createdAt: new Date("2026-01-01"), + expiresAt: new Date("2026-01-02"), + }) + mockAccountFindById.mockResolvedValueOnce({ id: INVITER, username: "alice" }) + + const result = await getInviteById("507f1f77bcf86cd799439011" as never) + + expect(result).toMatchObject({ + id: "invite-1", + contact: "friend@example.com", + inviterUsername: "alice", + status: InviteStatus.SENT, + }) + }) + + it("includes redeemer details for an accepted invite", async () => { + mockFindById.mockResolvedValue({ + _id: { toString: () => "invite-2" }, + contact: "+12025550123", + method: InviteMethod.WHATSAPP, + status: InviteStatus.ACCEPTED, + inviterId: { toString: () => INVITER }, + redeemedById: { toString: () => REDEEMER }, + createdAt: new Date(), + expiresAt: new Date(), + redeemedAt: new Date(), + }) + mockAccountFindById + .mockResolvedValueOnce({ id: INVITER, username: "alice" }) + .mockResolvedValueOnce({ id: REDEEMER, username: "bob" }) + + const result = await getInviteById("507f1f77bcf86cd799439011" as never) + + expect(result).toMatchObject({ + redeemerAccountId: REDEEMER, + redeemerUsername: "bob", + }) + }) +}) + +describe("listInvites", () => { + beforeEach(() => jest.clearAllMocks()) + + it("returns the paginated facet result", async () => { + const data = [{ id: "a" }, { id: "b" }] + mockAggregate.mockResolvedValue([{ data, count: [{ total: 2 }] }]) + + const result = await listInvites({ first: 10 }) + + expect(result).toEqual({ data, count: [{ total: 2 }] }) + expect(mockAggregate).toHaveBeenCalledTimes(1) + }) + + it("pages by _id cursor without restricting the total count", async () => { + mockAggregate.mockResolvedValue([{ data: [], count: [{ total: 9 }] }]) + const afterId = "507f1f77bcf86cd799439033" + + await listInvites({ first: 5, afterId }) + + const pipeline = mockAggregate.mock.calls[0][0] + // The cursor restriction lives inside the data facet (older than afterId, + // newest first) — never in the shared $match, so count covers everything. + expect(pipeline[0]).toEqual({ $match: {} }) + const dataPipeline = pipeline[1].$facet.data + expect(dataPipeline[0].$match._id.$lt.toString()).toBe(afterId) + expect(dataPipeline[1]).toEqual({ $sort: { _id: -1 } }) + expect(dataPipeline[2]).toEqual({ $limit: 5 }) + }) + + it("defaults data and count when the facet result omits them", async () => { + // NB: the `|| [{ total: 0 }]` fallback only fires for a missing key, not an + // empty array — an empty `count: []` from aggregate passes straight through. + mockAggregate.mockResolvedValue([{}]) + const result = await listInvites({}) + expect(result).toEqual({ data: [], count: [{ total: 0 }] }) + }) + + it("filters by status and casts inviterId to an ObjectId for the pipeline", async () => { + mockAggregate.mockResolvedValue([{ data: [], count: [] }]) + await listInvites({ status: InviteStatus.PENDING, inviterId: INVITER as AccountId }) + + const pipeline = mockAggregate.mock.calls[0][0] + const match = pipeline[0].$match + expect(match.status).toBe(InviteStatus.PENDING) + // Aggregation pipelines bypass mongoose casting: a raw string would + // silently match nothing against the ObjectId inviterId field. + expect(typeof match.inviterId).toBe("object") + expect(match.inviterId.toString()).toBe(INVITER) + }) +}) diff --git a/test/flash/unit/app/invite/rate-limits.spec.ts b/test/flash/unit/app/invite/rate-limits.spec.ts new file mode 100644 index 000000000..160cefb6f --- /dev/null +++ b/test/flash/unit/app/invite/rate-limits.spec.ts @@ -0,0 +1,56 @@ +import { RateLimitConfig } from "@domain/rate-limit" +import { InviteCreateRateLimiterExceededError } from "@domain/rate-limit/errors" + +const mockConsumeLimiter = jest.fn() +jest.mock("@services/rate-limit", () => ({ + consumeLimiter: (args: unknown) => mockConsumeLimiter(args), +})) + +import { + checkInviteCreateRateLimit, + checkInviteTargetRateLimit, +} from "@app/invite/rate-limits" + +describe("invite rate-limits", () => { + beforeEach(() => jest.clearAllMocks()) + + describe("checkInviteCreateRateLimit", () => { + it("consumes the inviteCreate limiter keyed by accountId and passes through true", async () => { + mockConsumeLimiter.mockResolvedValue(true) + const accountId = "507f1f77bcf86cd799439011" as AccountId + + const result = await checkInviteCreateRateLimit(accountId) + + expect(result).toBe(true) + expect(mockConsumeLimiter).toHaveBeenCalledWith({ + rateLimitConfig: RateLimitConfig.inviteCreate, + keyToConsume: accountId, + }) + }) + + it("returns the limiter error when exceeded", async () => { + const err = new InviteCreateRateLimiterExceededError() + mockConsumeLimiter.mockResolvedValue(err) + + const result = await checkInviteCreateRateLimit( + "507f1f77bcf86cd799439011" as AccountId, + ) + + expect(result).toBe(err) + }) + }) + + describe("checkInviteTargetRateLimit", () => { + it("consumes the inviteTarget limiter keyed by contact", async () => { + mockConsumeLimiter.mockResolvedValue(true) + + const result = await checkInviteTargetRateLimit("+12025550123") + + expect(result).toBe(true) + expect(mockConsumeLimiter).toHaveBeenCalledWith({ + rateLimitConfig: RateLimitConfig.inviteTarget, + keyToConsume: "+12025550123", + }) + }) + }) +}) diff --git a/test/flash/unit/domain/invite/index.spec.ts b/test/flash/unit/domain/invite/index.spec.ts new file mode 100644 index 000000000..481f13548 --- /dev/null +++ b/test/flash/unit/domain/invite/index.spec.ts @@ -0,0 +1,74 @@ +import { + INVITE_EXPIRY_HOURS, + DAILY_INVITE_LIMIT, + TARGET_INVITE_LIMIT, + NEW_USER_INVITE_WINDOW_HOURS, + INVITE_TOKEN_LENGTH, + checkedToInviteId, + checkedToInviteToken, + InvalidInviteIdError, + InviteAlreadyAcceptedError, + InvalidExpirationDateError, +} from "@domain/invite" +import { ValidationError } from "@domain/shared" + +describe("invite domain constants", () => { + it("exposes the expected invariants", () => { + expect(INVITE_EXPIRY_HOURS).toBe(24) + expect(DAILY_INVITE_LIMIT).toBe(10) + expect(TARGET_INVITE_LIMIT).toBe(3) + expect(NEW_USER_INVITE_WINDOW_HOURS).toBe(24) + expect(INVITE_TOKEN_LENGTH).toBe(40) + }) +}) + +describe("checkedToInviteId", () => { + it("accepts a 24-character id", () => { + const id = "507f1f77bcf86cd799439011" + expect(checkedToInviteId(id)).toBe(id) + }) + + it.each(["", "tooshort", "507f1f77bcf86cd7994390110000"])( + "rejects an id of the wrong length: %s", + (id) => { + const result = checkedToInviteId(id) + expect(result).toBeInstanceOf(InvalidInviteIdError) + }, + ) +}) + +describe("checkedToInviteToken", () => { + it("accepts a 40-char lowercase hex token", () => { + const token = "a".repeat(40) + expect(checkedToInviteToken(token)).toBe(token) + }) + + it("accepts uppercase hex (case-insensitive)", () => { + const token = "AB".repeat(20) + expect(checkedToInviteToken(token)).toBe(token) + }) + + it("rejects an empty token", () => { + expect(checkedToInviteToken("")).toBeInstanceOf(ValidationError) + }) + + it("rejects the wrong length", () => { + const result = checkedToInviteToken("a".repeat(39)) + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe("Invalid invitation token length") + }) + + it("rejects non-hex characters at the right length", () => { + const result = checkedToInviteToken("g".repeat(40)) + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe("Invalid invitation token format") + }) +}) + +describe("invite domain errors", () => { + it("are all ValidationErrors", () => { + expect(new InvalidInviteIdError("x")).toBeInstanceOf(ValidationError) + expect(new InviteAlreadyAcceptedError("x")).toBeInstanceOf(ValidationError) + expect(new InvalidExpirationDateError("x")).toBeInstanceOf(ValidationError) + }) +}) diff --git a/test/flash/unit/domain/invite/referral-reward.spec.ts b/test/flash/unit/domain/invite/referral-reward.spec.ts new file mode 100644 index 000000000..664705428 --- /dev/null +++ b/test/flash/unit/domain/invite/referral-reward.spec.ts @@ -0,0 +1,44 @@ +import { referralRewardAmountCents } from "@domain/invite/referral-reward" + +const DEFAULT_TIERS = [ + { upToCount: 100, amountCents: 500 }, + { upToCount: 600, amountCents: 250 }, + { upToCount: 0, amountCents: 100 }, +] + +describe("referralRewardAmountCents", () => { + describe("default tiered schedule", () => { + it.each([ + [1, 500], + [100, 500], + [101, 250], + [600, 250], + [601, 100], + [5000, 100], + ])("seq %i -> %i cents", (seq, expected) => { + expect(referralRewardAmountCents(DEFAULT_TIERS, seq)).toBe(expected) + }) + }) + + it("returns 0 when there are no tiers", () => { + expect(referralRewardAmountCents([], 1)).toBe(0) + expect(referralRewardAmountCents([], 999)).toBe(0) + }) + + it("treats a single unbounded tier as covering every sequence", () => { + const tiers = [{ upToCount: 0, amountCents: 100 }] + expect(referralRewardAmountCents(tiers, 1)).toBe(100) + expect(referralRewardAmountCents(tiers, 1_000_000)).toBe(100) + }) + + it("pays 0 past the final bound when the unbounded sentinel is missing (misconfig fail-safe)", () => { + // Operator forgot the { upToCount: 0 } sentinel: never over-pay forever. + expect(referralRewardAmountCents([{ upToCount: 100, amountCents: 500 }], 101)).toBe(0) + const tiers = [ + { upToCount: 10, amountCents: 500 }, + { upToCount: 20, amountCents: 250 }, + ] + expect(referralRewardAmountCents(tiers, 20)).toBe(250) // still within bounds + expect(referralRewardAmountCents(tiers, 25)).toBe(0) // past all bounds + }) +}) diff --git a/test/flash/unit/domain/invite/validation.spec.ts b/test/flash/unit/domain/invite/validation.spec.ts new file mode 100644 index 000000000..ed6218d09 --- /dev/null +++ b/test/flash/unit/domain/invite/validation.spec.ts @@ -0,0 +1,92 @@ +import { + validateEmail, + validatePhone, + validateContactForMethod, +} from "@domain/invite/validation" +import { InviteMethod } from "@services/mongoose/models/invite" +import { ValidationError } from "@domain/shared" + +describe("invite validation", () => { + describe("validateEmail", () => { + it.each([ + "test@example.com", + "a@b.co", + "user.name+tag@sub.domain.io", + "UPPER@Case.COM", + ])("accepts valid email %s", (email) => { + expect(validateEmail(email)).toBe(true) + }) + + it.each([ + "plainaddress", + "no@domainwithoutdot", + "@no-local.com", + "missing@dot", + "a@.co", + "spaces in@email.com", + "trailing@space.com ", + "", + ])("rejects invalid email %s", (email) => { + expect(validateEmail(email)).toBe(false) + }) + }) + + describe("validatePhone", () => { + it.each(["+12025550123", "+18765551234", "+447911123456", "+12345678"])( + "accepts valid E.164 phone %s", + (phone) => { + expect(validatePhone(phone)).toBe(true) + }, + ) + + it.each([ + "12025550123", // missing + + "+0123456789", // leading zero after + + "+123", // too short + "+1202555012a", // non-digit + "+1 202 555 0123", // spaces + "++12025550123", // double plus + "", + ])("rejects invalid phone %s", (phone) => { + expect(validatePhone(phone)).toBe(false) + }) + }) + + describe("validateContactForMethod", () => { + it("accepts a valid email for EMAIL", () => { + expect(validateContactForMethod("test@example.com", InviteMethod.EMAIL)).toBe(true) + }) + + it("rejects an invalid email for EMAIL with a ValidationError", () => { + const result = validateContactForMethod("nope", InviteMethod.EMAIL) + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe("Invalid email format") + }) + + it("accepts a valid phone for SMS and WHATSAPP", () => { + expect(validateContactForMethod("+12025550123", InviteMethod.SMS)).toBe(true) + expect(validateContactForMethod("+12025550123", InviteMethod.WHATSAPP)).toBe(true) + }) + + it("rejects an invalid phone for SMS/WHATSAPP with a ValidationError", () => { + const result = validateContactForMethod("12025550123", InviteMethod.WHATSAPP) + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe("Invalid phone number format") + }) + + it("rejects an unknown method with a ValidationError", () => { + const result = validateContactForMethod( + "test@example.com", + "CARRIER_PIGEON" as InviteMethod, + ) + expect(result).toBeInstanceOf(ValidationError) + expect((result as ValidationError).message).toBe("Invalid invite method") + }) + + it("does not accept an email when the method is a phone method", () => { + expect( + validateContactForMethod("test@example.com", InviteMethod.SMS), + ).toBeInstanceOf(ValidationError) + }) + }) +}) diff --git a/test/flash/unit/graphql/public/root/mutation/redeem-invite.spec.ts b/test/flash/unit/graphql/public/root/mutation/redeem-invite.spec.ts new file mode 100644 index 000000000..926db31cb --- /dev/null +++ b/test/flash/unit/graphql/public/root/mutation/redeem-invite.spec.ts @@ -0,0 +1,210 @@ +const mockInviteFindOne = jest.fn() +const mockInviteExists = jest.fn() +jest.mock("@services/mongoose/models/invite", () => { + const actual = jest.requireActual("@services/mongoose/models/invite") + return { + InviteMethod: actual.InviteMethod, + InviteStatus: actual.InviteStatus, + InviteRepository: { + findOne: (...a: unknown[]) => mockInviteFindOne(...a), + exists: (...a: unknown[]) => mockInviteExists(...a), + }, + } +}) + +const mockAccountFindById = jest.fn() +const mockUserFindById = jest.fn() +jest.mock("@services/mongoose", () => ({ + AccountsRepository: () => ({ + findById: (...a: unknown[]) => mockAccountFindById(...a), + }), + UsersRepository: () => ({ findById: (...a: unknown[]) => mockUserFindById(...a) }), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import RedeemInviteMutation from "@graphql/public/root/mutation/redeem-invite" +import { InviteStatus } from "@services/mongoose/models/invite" + +// 24-hex ids: the resolver constructs mongoose ObjectIds from them. +const REDEEMER_ACCOUNT = "507f1f77bcf86cd799439011" +const INVITER_ACCOUNT = "507f1f77bcf86cd799439022" +const TOKEN = "a".repeat(40) + +type RedeemResult = { success: boolean; errors: string[] } + +const ctx = { + user: { id: "user-1" }, + domainAccount: { id: REDEEMER_ACCOUNT, username: "bob" }, +} as unknown as GraphQLPublicContextAuth + +const resolveRedeem = async ( + token = TOKEN, + context: unknown = ctx, +): Promise => { + const mutation = RedeemInviteMutation as unknown as { + resolve: ( + source: null, + args: { input: { token: string } }, + context: unknown, + info: never, + ) => Promise + } + return mutation.resolve(null, { input: { token } }, context, undefined as never) +} + +const hourMs = 60 * 60 * 1000 + +const baseInvite = (overrides: Record = {}) => ({ + _id: "invite-1", + contact: "+18765550100", + method: "WHATSAPP", + status: InviteStatus.SENT, + inviterId: { toString: () => INVITER_ACCOUNT }, + expiresAt: new Date(Date.now() + 12 * hourMs), + revokedAt: undefined as Date | undefined, + redeemedAt: undefined as Date | undefined, + save: jest.fn(), + ...overrides, +}) + +const freshAccount = () => ({ createdAt: new Date(Date.now() - 1 * hourMs) }) +const staleAccount = () => ({ createdAt: new Date(Date.now() - 48 * hourMs) }) + +describe("redeemInvite resolver", () => { + beforeEach(() => { + jest.clearAllMocks() + mockInviteExists.mockResolvedValue(null) // no prior redemption + mockAccountFindById.mockResolvedValue(freshAccount()) + mockUserFindById.mockResolvedValue({ phone: "+18765550100" }) + }) + + it("rejects a malformed token without hitting the database", async () => { + const result = await resolveRedeem("not-a-token") + expect(result.success).toBe(false) + expect(result.errors[0]).toMatch(/invalid invitation token/i) + expect(mockInviteFindOne).not.toHaveBeenCalled() + }) + + it("requires an authenticated account", async () => { + const result = await resolveRedeem(TOKEN, { user: null, domainAccount: null }) + expect(result.success).toBe(false) + expect(result.errors[0]).toMatch(/authentication required/i) + }) + + it("rejects an unknown token", async () => { + mockInviteFindOne.mockResolvedValue(null) + const result = await resolveRedeem() + expect(result).toEqual({ success: false, errors: ["Invalid or expired invitation"] }) + }) + + it("expires a date-expired invite and rejects it", async () => { + const invite = baseInvite({ expiresAt: new Date(Date.now() - hourMs) }) + mockInviteFindOne.mockResolvedValue(invite) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("This invitation has expired") + expect(invite.status).toBe(InviteStatus.EXPIRED) + expect(invite.save).toHaveBeenCalled() + }) + + it("rejects an already-used invite", async () => { + mockInviteFindOne.mockResolvedValue(baseInvite({ status: InviteStatus.ACCEPTED })) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("This invitation has already been used") + }) + + it("does not flip an ACCEPTED invite to EXPIRED on a post-expiry replay", async () => { + // The ACCEPTED check must precede the date-expiry flip: overwriting + // ACCEPTED would strand the pending reward and, via the one-redemption- + // per-account invariant, permanently cost the account its referral. + const invite = baseInvite({ + status: InviteStatus.ACCEPTED, + expiresAt: new Date(Date.now() - hourMs), + }) + mockInviteFindOne.mockResolvedValue(invite) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("This invitation has already been used") + expect(invite.status).toBe(InviteStatus.ACCEPTED) + expect(invite.save).not.toHaveBeenCalled() + }) + + it("rejects a revoked invite even when its expiry date is in the future", async () => { + mockInviteFindOne.mockResolvedValue( + baseInvite({ status: InviteStatus.EXPIRED, revokedAt: new Date() }), + ) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("This invitation is no longer valid") + }) + + it("rejects a revokedAt-stamped invite regardless of status", async () => { + mockInviteFindOne.mockResolvedValue(baseInvite({ revokedAt: new Date() })) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("This invitation is no longer valid") + }) + + it("rejects self-redemption", async () => { + mockInviteFindOne.mockResolvedValue( + baseInvite({ inviterId: { toString: () => REDEEMER_ACCOUNT } }), + ) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("You cannot redeem your own invitation") + }) + + it("rejects a second redemption by the same account (one reward per invitee)", async () => { + mockInviteFindOne.mockResolvedValue(baseInvite()) + mockInviteExists.mockResolvedValue({ _id: "earlier-invite" }) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("You have already redeemed an invitation") + const existsFilter = mockInviteExists.mock.calls[0][0] + expect(existsFilter.status).toBe(InviteStatus.ACCEPTED) + expect(existsFilter.redeemedById.toString()).toBe(REDEEMER_ACCOUNT) + }) + + it("treats a duplicate-key race on save as already redeemed", async () => { + const invite = baseInvite() + invite.save.mockRejectedValue(Object.assign(new Error("dup"), { code: 11000 })) + mockInviteFindOne.mockResolvedValue(invite) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("You have already redeemed an invitation") + }) + + it("rejects accounts older than the new-user window", async () => { + mockInviteFindOne.mockResolvedValue(baseInvite()) + mockAccountFindById.mockResolvedValue(staleAccount()) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("This invitation is for new users only") + }) + + it("rejects a phone-method invite when the redeemer's phone differs", async () => { + mockInviteFindOne.mockResolvedValue(baseInvite()) + mockUserFindById.mockResolvedValue({ phone: "+18765559999" }) + const result = await resolveRedeem() + expect(result.errors[0]).toBe("This invitation was sent to a different phone number") + }) + + it("does not phone-match EMAIL invites (identity check deferred)", async () => { + const invite = baseInvite({ method: "EMAIL", contact: "friend@example.com" }) + mockInviteFindOne.mockResolvedValue(invite) + mockUserFindById.mockResolvedValue({ phone: "+18765559999" }) + const result = await resolveRedeem() + expect(result.success).toBe(true) + }) + + it("marks the invite accepted with the redeemer on success", async () => { + const invite = baseInvite() + mockInviteFindOne.mockResolvedValue(invite) + const result = await resolveRedeem() + + expect(result).toEqual({ success: true, errors: [] }) + expect(invite.status).toBe(InviteStatus.ACCEPTED) + expect(invite.redeemedAt).toBeInstanceOf(Date) + expect( + ( + invite as unknown as { redeemedById: { toString(): string } } + ).redeemedById.toString(), + ).toBe(REDEEMER_ACCOUNT) + expect(invite.save).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/flash/unit/graphql/shared/types/scalar/timestamp.spec.ts b/test/flash/unit/graphql/shared/types/scalar/timestamp.spec.ts new file mode 100644 index 000000000..048c0deb6 --- /dev/null +++ b/test/flash/unit/graphql/shared/types/scalar/timestamp.spec.ts @@ -0,0 +1,39 @@ +import { Kind } from "graphql" + +import { InputValidationError } from "@graphql/error" +import Timestamp from "@graphql/shared/types/scalar/timestamp" + +describe("Timestamp scalar", () => { + it("serializes a Date as Unix seconds", () => { + expect(Timestamp.serialize(new Date("2026-07-30T12:00:00Z"))).toBe(1785412800) + }) + + it("parses a numeric value as Unix seconds", () => { + expect(Timestamp.parseValue(1785412800)).toEqual(new Date("2026-07-30T12:00:00Z")) + }) + + it("parses a pure-digit string as Unix seconds", () => { + expect(Timestamp.parseValue("1785412800")).toEqual(new Date("2026-07-30T12:00:00Z")) + }) + + it("parses an ISO-8601 string as that date — not as parseInt seconds", () => { + // Regression: parseInt("2026-07-30T…") === 2026 silently produced a 1970 + // date, corrupting admin-supplied values like cutover scheduledAt. + expect(Timestamp.parseValue("2026-07-30T12:00:00Z")).toEqual( + new Date("2026-07-30T12:00:00Z"), + ) + }) + + it("rejects an unparsable date string", () => { + expect(Timestamp.parseValue("not-a-date")).toBeInstanceOf(InputValidationError) + }) + + it("parses INT and STRING literals consistently", () => { + expect(Timestamp.parseLiteral({ kind: Kind.INT, value: "1785412800" }, null)).toEqual( + new Date("2026-07-30T12:00:00Z"), + ) + expect( + Timestamp.parseLiteral({ kind: Kind.STRING, value: "2026-07-30T12:00:00Z" }, null), + ).toEqual(new Date("2026-07-30T12:00:00Z")) + }) +}) diff --git a/test/flash/unit/utils/hash.spec.ts b/test/flash/unit/utils/hash.spec.ts new file mode 100644 index 000000000..ed1416659 --- /dev/null +++ b/test/flash/unit/utils/hash.spec.ts @@ -0,0 +1,59 @@ +import { sha256, generateSecureToken, hashToken, generateInviteToken } from "@utils" + +describe("sha256", () => { + it("matches known vectors", () => { + expect(sha256("")).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ) + expect(sha256("abc")).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ) + }) + + it("is deterministic", () => { + expect(sha256("flash")).toBe(sha256("flash")) + }) + + it("produces different digests for different inputs", () => { + expect(sha256("a")).not.toBe(sha256("b")) + }) + + it("returns 64 hex characters", () => { + expect(sha256("anything")).toMatch(/^[a-f0-9]{64}$/) + }) +}) + +describe("generateSecureToken", () => { + it("defaults to 20 bytes (40 hex chars)", () => { + expect(generateSecureToken()).toMatch(/^[a-f0-9]{40}$/) + }) + + it("honours a custom byte length", () => { + expect(generateSecureToken(32)).toMatch(/^[a-f0-9]{64}$/) + }) + + it("is effectively random across calls", () => { + const tokens = new Set(Array.from({ length: 100 }, () => generateSecureToken())) + expect(tokens.size).toBe(100) + }) +}) + +describe("hashToken", () => { + it("is sha256 of the token", () => { + const token = "deadbeef" + expect(hashToken(token)).toBe(sha256(token)) + }) +}) + +describe("generateInviteToken", () => { + it("returns a 40-hex token and its sha256 hash", () => { + const { token, tokenHash } = generateInviteToken() + expect(token).toMatch(/^[a-f0-9]{40}$/) + expect(tokenHash).toMatch(/^[a-f0-9]{64}$/) + expect(tokenHash).toBe(sha256(token)) + }) + + it("produces a unique token each call", () => { + expect(generateInviteToken().token).not.toBe(generateInviteToken().token) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 00c3ecf11..3a861ac7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "@domain/*": ["src/domain/*"], "@services/*": ["src/services/*"], "@utils": ["src/utils/index"], + "@utils/*": ["src/utils/*"], "@graphql/*": ["src/graphql/*"], "@servers/*": ["src/servers/*"] }