Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,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)
{
Expand Down Expand Up @@ -663,6 +677,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)
{
Expand Down Expand Up @@ -1037,6 +1089,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!
Expand Down Expand Up @@ -1139,6 +1192,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).
Expand Down Expand Up @@ -1493,6 +1547,7 @@ type Query
businessMapMarkers: [MapMarker!]!
currencyList: [Currency!]!
globals: Globals
invitePreview(token: String!): InvitePreview
isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload
lnInvoicePaymentStatus(input: LnInvoicePaymentStatusInput!): LnInvoicePaymentStatusPayload!
me: User
Expand Down Expand Up @@ -1567,6 +1622,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)
{
Expand Down
51 changes: 45 additions & 6 deletions dev/bin/gen-test-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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()
4 changes: 4 additions & 0 deletions src/app/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export * from "./update-user-phone"
export * from "./send-admin-push-notification"
export * from "./send-broadcast-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"
Expand Down
183 changes: 183 additions & 0 deletions src/app/admin/invite.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading