Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
6 changes: 5 additions & 1 deletion apps/api/src/app/providers/jobs.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { DeleteUnbackedDeploymentSettingHandler } from "@src/deployment/services
import { ReconcileManagedTxHandler } from "@src/deployment/services/reconcile-managed-tx/reconcile-managed-tx.handler";
import { RecordDeploymentSettingHandler } from "@src/deployment/services/record-deployment-setting/record-deployment-setting.handler";
import { NotificationHandler } from "@src/notifications/services/notification-handler/notification.handler";
import { BlockEmailDomainOfWalletHandler } from "@src/workload-abuse/services/block-email-domain-of-wallet/block-email-domain-of-wallet.handler";
import { EnforceTrialAbuseHandler } from "@src/workload-abuse/services/enforce-trial-abuse/enforce-trial-abuse.handler";
import { LockBlockedDomainWalletHandler } from "@src/workload-abuse/services/lock-blocked-domain-wallet/lock-blocked-domain-wallet.handler";
import { ProbeTrialDeploymentHandler } from "@src/workload-abuse/services/probe-trial-deployment/probe-trial-deployment.handler";
import { AutoRechargeSucceededHandler } from "../services/auto-recharge-succeeded/auto-recharge-succeeded.handler";
import { CloseExpiredDeploymentHandler } from "../services/close-expired-deployment/close-expired-deployment.handler";
Expand Down Expand Up @@ -46,7 +48,9 @@ export async function startJobQueues(): Promise<void> {
container.resolve(RecordDeploymentSettingHandler),
container.resolve(ReconcileManagedTxHandler),
container.resolve(ProbeTrialDeploymentHandler),
container.resolve(EnforceTrialAbuseHandler)
container.resolve(EnforceTrialAbuseHandler),
container.resolve(LockBlockedDomainWalletHandler),
container.resolve(BlockEmailDomainOfWalletHandler)
]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,64 @@ describe(StripeTransactionRepository.name, () => {
};
}

describe("hasPaidUserWithEmailDomain", () => {
it.each([
{ status: "succeeded" as const, expected: true },
{ status: "refunded" as const, expected: true },
{ status: "created" as const, expected: false },
{ status: "failed" as const, expected: false }
])("answers $expected for a payment_intent in $status", async ({ status, expected }) => {
const { stripeTransactionRepository, createUserOnDomain } = setup();
const domain = uniqueDomain();
const user = await createUserOnDomain(domain);
await stripeTransactionRepository.create({ userId: user.id, type: "payment_intent", status, amount: 1000, currency: "usd" });

await expect(stripeTransactionRepository.hasPaidUserWithEmailDomain(domain)).resolves.toBe(expected);
});

it.each([["manual_credit" as const], ["coupon_claim" as const]])("does not count a %s, so a granted credit cannot shield a domain", async type => {
const { stripeTransactionRepository, createUserOnDomain } = setup();
const domain = uniqueDomain();
const user = await createUserOnDomain(domain);
await stripeTransactionRepository.create({ userId: user.id, type, status: "succeeded", amount: 1000, currency: "usd" });

await expect(stripeTransactionRepository.hasPaidUserWithEmailDomain(domain)).resolves.toBe(false);
});

it("matches the domain part only, never a domain that merely contains it", async () => {
const { stripeTransactionRepository, createUserOnDomain } = setup();
const domain = uniqueDomain();
const lookalikes = [`x${domain}`, `${domain}.attacker.net`, `mail.${domain}`];
for (const lookalike of lookalikes) {
const user = await createUserOnDomain(lookalike);
await stripeTransactionRepository.create({ userId: user.id, type: "payment_intent", status: "succeeded", amount: 1000, currency: "usd" });
}

await expect(stripeTransactionRepository.hasPaidUserWithEmailDomain(domain)).resolves.toBe(false);
});

it("matches a stored address whatever its case", async () => {
const { stripeTransactionRepository, createUserOnDomain } = setup();
const domain = uniqueDomain();
const user = await createUserOnDomain(domain.toUpperCase());
await stripeTransactionRepository.create({ userId: user.id, type: "payment_intent", status: "succeeded", amount: 1000, currency: "usd" });

await expect(stripeTransactionRepository.hasPaidUserWithEmailDomain(domain)).resolves.toBe(true);
});

it("answers false for a domain nobody has paid from", async () => {
const { stripeTransactionRepository, createUserOnDomain } = setup();
const domain = uniqueDomain();
await createUserOnDomain(domain);

await expect(stripeTransactionRepository.hasPaidUserWithEmailDomain(domain)).resolves.toBe(false);
});
});

function uniqueDomain() {
return `${faker.string.alphanumeric(16).toLowerCase()}.com`;
}

function setup() {
const stripeTransactionRepository = container.resolve(StripeTransactionRepository);
const userRepository = container.resolve(UserRepository);
Expand All @@ -292,6 +350,12 @@ describe(StripeTransactionRepository.name, () => {
return testUserId;
}

async function createUserOnDomain(domain: string) {
const user = await userRepository.create({ userId: faker.string.uuid(), email: `${faker.string.alphanumeric(10)}@${domain}` });
createdUserIds.push(user.id);
return user;
}

async function createTestTransaction(overrides: Partial<StripeTransactionInput> = {}) {
return stripeTransactionRepository.create({
userId: await getTestUserId(),
Expand All @@ -309,6 +373,6 @@ describe(StripeTransactionRepository.name, () => {
return user;
}

return { stripeTransactionRepository, userRepository, createTestTransaction, createTestUser };
return { stripeTransactionRepository, userRepository, createTestTransaction, createTestUser, createUserOnDomain };
}
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { and, count, desc, eq, gte, inArray, lte, notInArray, SQL, sql } from "drizzle-orm";
import { and, count, desc, eq, exists, gte, inArray, lte, notInArray, SQL, sql } from "drizzle-orm";
import { singleton } from "tsyringe";

import { type ApiPgDatabase, type ApiPgTables, InjectPg, InjectPgTable } from "@src/core/providers";
import { type AbilityParams, BaseRepository } from "@src/core/repositories/base.repository";
import { TxService } from "@src/core/services";
import { Users } from "@src/user/model-schemas";

type Table = ApiPgTables["StripeTransactions"];
export type StripeTransactionInput = Table["$inferInsert"];
Expand Down Expand Up @@ -176,6 +177,31 @@ export class StripeTransactionRepository extends BaseRepository<Table, StripeTra
return !!item;
}

/**
* Whether any account on the domain has ever made a real purchase. Manual credits and coupon claims
* deliberately do not count: both are granted to trial users, so counting them would let a comped
* account shield a domain from being blocked.
*/
async hasPaidUserWithEmailDomain(domain: string): Promise<boolean> {
const [match] = await this.cursor
.select({ id: Users.id })
.from(Users)
.where(
and(
sql`lower(${Users.email}) LIKE ${"%@"} || ${domain}`,
exists(
this.cursor
.select({ id: this.table.id })
.from(this.table)
.where(and(eq(this.table.userId, Users.id), eq(this.table.type, "payment_intent"), inArray(this.table.status, ["succeeded", "refunded"])))
)
)
)
.limit(1);

return !!match;
}

async countByUserId(userId: string, options?: { startDate?: Date; endDate?: Date }): Promise<number> {
const conditions: SQL[] = [eq(this.table.userId, userId)];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import subMinutes from "date-fns/subMinutes";
import { container } from "tsyringe";
import { describe, expect, it } from "vitest";

import { StripeTransactionRepository } from "@src/billing/repositories/stripe-transaction/stripe-transaction.repository";
import { UserRepository } from "@src/user/repositories";
import { UserWalletRepository } from "./user-wallet.repository";

Expand Down Expand Up @@ -233,6 +234,101 @@ describe(UserWalletRepository.name, () => {
});
});

describe("findLockableTrialWalletsByEmailDomain", () => {
it("returns the trialing, unlocked, never-paid wallets on the domain", async () => {
const { domain, createWalletOnDomain, userWalletRepository } = await setupDomain();
const target = await createWalletOnDomain({});
const trigger = await createWalletOnDomain({});

const lockable = await userWalletRepository.findLockableTrialWalletsByEmailDomain(domain, { excludeWalletId: trigger.id, limit: 10 });

expect(lockable).toEqual([{ walletId: target.id, userId: target.userId }]);
});

it.each([
{ label: "already locked", overrides: { abuseLockedAt: new Date(), abuseLockedReason: "workload_abuse" } },
{ label: "no longer trialing", overrides: { isTrialing: false } }
])("excludes a wallet that is $label", async ({ overrides }) => {
const { domain, createWalletOnDomain, userWalletRepository } = await setupDomain();
await createWalletOnDomain(overrides);
const trigger = await createWalletOnDomain({});

const lockable = await userWalletRepository.findLockableTrialWalletsByEmailDomain(domain, { excludeWalletId: trigger.id, limit: 10 });

expect(lockable).toEqual([]);
});

it("excludes a wallet whose owner has ever paid", async () => {
const { domain, createWalletOnDomain, userWalletRepository, stripeTransactionRepository } = await setupDomain();
const paid = await createWalletOnDomain({});
await stripeTransactionRepository.create({ userId: paid.userId, type: "payment_intent", status: "succeeded", amount: 1000, currency: "usd" });
const trigger = await createWalletOnDomain({});

const lockable = await userWalletRepository.findLockableTrialWalletsByEmailDomain(domain, { excludeWalletId: trigger.id, limit: 10 });

expect(lockable).toEqual([]);
});

it("still returns a wallet whose owner only ever received a manual credit", async () => {
const { domain, createWalletOnDomain, userWalletRepository, stripeTransactionRepository } = await setupDomain();
const comped = await createWalletOnDomain({});
await stripeTransactionRepository.create({ userId: comped.userId, type: "manual_credit", status: "succeeded", amount: 1000, currency: "usd" });
const trigger = await createWalletOnDomain({});

const lockable = await userWalletRepository.findLockableTrialWalletsByEmailDomain(domain, { excludeWalletId: trigger.id, limit: 10 });

expect(lockable).toEqual([{ walletId: comped.id, userId: comped.userId }]);
});

it("excludes the wallet that triggered the block", async () => {
const { domain, createWalletOnDomain, userWalletRepository } = await setupDomain();
const trigger = await createWalletOnDomain({});

const lockable = await userWalletRepository.findLockableTrialWalletsByEmailDomain(domain, { excludeWalletId: trigger.id, limit: 10 });

expect(lockable).toEqual([]);
});

it("matches the domain part only, never a domain that merely contains it", async () => {
const { domain, createWalletOnDomain, userWalletRepository } = await setupDomain();
await createWalletOnDomain({}, `x${domain}`);
await createWalletOnDomain({}, `${domain}.attacker.net`);
await createWalletOnDomain({}, `mail.${domain}`);
const trigger = await createWalletOnDomain({});

const lockable = await userWalletRepository.findLockableTrialWalletsByEmailDomain(domain, { excludeWalletId: trigger.id, limit: 10 });

expect(lockable).toEqual([]);
});

it("returns no more wallets than the limit allows", async () => {
const { domain, createWalletOnDomain, userWalletRepository } = await setupDomain();
await createWalletOnDomain({});
await createWalletOnDomain({});
await createWalletOnDomain({});
const trigger = await createWalletOnDomain({});

const lockable = await userWalletRepository.findLockableTrialWalletsByEmailDomain(domain, { excludeWalletId: trigger.id, limit: 2 });

expect(lockable).toHaveLength(2);
});
});

async function setupDomain() {
const userRepository = container.resolve(UserRepository);
const userWalletRepository = container.resolve(UserWalletRepository);
const stripeTransactionRepository = container.resolve(StripeTransactionRepository);
const domain = `${faker.string.alphanumeric(16).toLowerCase()}.com`;

async function createWalletOnDomain(overrides: Parameters<UserWalletRepository["updateById"]>[1], onDomain = domain) {
const user = await userRepository.create({ userId: faker.string.uuid(), email: `${faker.string.alphanumeric(10)}@${onDomain}` });
const created = await userWalletRepository.create({ userId: user.id, address: createAkashAddress() });
return await userWalletRepository.updateById(created.id, { isTrialing: true, abuseLockedAt: null, ...overrides }, { returning: true });
}

return { domain, createWalletOnDomain, userRepository, userWalletRepository, stripeTransactionRepository };
}

async function setup(input: { creditsLowNotifiedAt?: Date; creditsSufficientSince?: Date; creditsLowSince?: Date } = {}) {
const userRepository = container.resolve(UserRepository);
const userWalletRepository = container.resolve(UserWalletRepository);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Trace } from "@akashnetwork/instrumentation";
import subDays from "date-fns/subDays";
import { and, count, eq, gt, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-orm";
import { and, count, eq, gt, inArray, isNotNull, isNull, lte, ne, notExists, or, sql } from "drizzle-orm";
import { singleton } from "tsyringe";

import { StripeTransactions } from "@src/billing/model-schemas";
import { type ApiPgDatabase, type ApiPgTables, InjectPg, InjectPgTable } from "@src/core/providers";
import { type AbilityParams, BaseRepository } from "@src/core/repositories/base.repository";
import { TxService } from "@src/core/services";
import { Users } from "@src/user/model-schemas";

export type DbCreateUserWalletInput = ApiPgTables["UserWallets"]["$inferInsert"];
export type DbUserWalletInput = Partial<DbCreateUserWalletInput>;
Expand Down Expand Up @@ -166,6 +168,42 @@ export class UserWalletRepository extends BaseRepository<ApiPgTables["UserWallet
);
}

/**
* Trial wallets on a domain that are still worth wiping. The paid check is redundant while a domain is
* auto-blocked — the guardrail already proved nobody paid — but keeps the sweep correct when it runs
* for a domain an operator blocked by hand. The limit bounds the damage of a match that is too broad.
*/
async findLockableTrialWalletsByEmailDomain(
domain: string,
options: { excludeWalletId: number; limit: number }
): Promise<Array<{ walletId: number; userId: string }>> {
return await this.cursor
.select({ walletId: this.table.id, userId: this.table.userId })
.from(this.table)
.innerJoin(Users, eq(Users.id, this.table.userId))
.where(
and(
sql`lower(${Users.email}) LIKE ${"%@"} || ${domain}`,
eq(this.table.isTrialing, true),
isNull(this.table.abuseLockedAt),
ne(this.table.id, options.excludeWalletId),
notExists(
this.cursor
.select({ id: StripeTransactions.id })
.from(StripeTransactions)
.where(
and(
eq(StripeTransactions.userId, Users.id),
eq(StripeTransactions.type, "payment_intent"),
inArray(StripeTransactions.status, ["succeeded", "refunded"])
)
)
)
)
)
.limit(options.limit);
}

/** One write, so a wallet is never left with zeroed allowances but no lock or the other way round. */
async lockForAbuse(id: UserWalletOutput["id"], reason: string): Promise<void> {
await this.updateById(id, { deploymentAllowance: 0, feeAllowance: 0, isTrialing: false, abuseLockedAt: new Date(), abuseLockedReason: reason });
Expand Down
63 changes: 63 additions & 0 deletions apps/api/src/user/repositories/user/user.repository.integration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { faker } from "@faker-js/faker";
import subDays from "date-fns/subDays";
import { container } from "tsyringe";
import { afterEach, describe, expect, it } from "vitest";

Expand Down Expand Up @@ -142,6 +143,68 @@ describe(UserRepository.name, () => {
};
}

describe("hasEstablishedUserWithEmailDomain", () => {
it("answers true for a domain with an account older than the window", async () => {
const { userRepository, domain, createUserOnDomain, someoneElse } = setupDomain();
await createUserOnDomain({ createdAt: subDays(new Date(), 45) });

await expect(userRepository.hasEstablishedUserWithEmailDomain(domain, 30, someoneElse)).resolves.toBe(true);
});

it("answers false when every account on the domain is newer than the window", async () => {
const { userRepository, domain, createUserOnDomain, someoneElse } = setupDomain();
await createUserOnDomain({ createdAt: subDays(new Date(), 5) });

await expect(userRepository.hasEstablishedUserWithEmailDomain(domain, 30, someoneElse)).resolves.toBe(false);
});

it("ignores the excluded account, so aging one signup cannot vouch for its own domain", async () => {
const { userRepository, domain, createUserOnDomain } = setupDomain();
const attacker = await createUserOnDomain({ createdAt: subDays(new Date(), 45) });

await expect(userRepository.hasEstablishedUserWithEmailDomain(domain, 30, attacker.id)).resolves.toBe(false);
});

it("still answers true when another account on the domain predates the window", async () => {
const { userRepository, domain, createUserOnDomain } = setupDomain();
const attacker = await createUserOnDomain({ createdAt: subDays(new Date(), 45) });
await createUserOnDomain({ createdAt: subDays(new Date(), 45) });

await expect(userRepository.hasEstablishedUserWithEmailDomain(domain, 30, attacker.id)).resolves.toBe(true);
});

it("matches the domain part only, never a domain that merely contains it", async () => {
const { userRepository, domain, createUserOnDomain, someoneElse } = setupDomain();
await createUserOnDomain({ createdAt: subDays(new Date(), 45) }, `x${domain}`);
await createUserOnDomain({ createdAt: subDays(new Date(), 45) }, `${domain}.attacker.net`);
await createUserOnDomain({ createdAt: subDays(new Date(), 45) }, `mail.${domain}`);

await expect(userRepository.hasEstablishedUserWithEmailDomain(domain, 30, someoneElse)).resolves.toBe(false);
});

it("matches a stored address whatever its case", async () => {
const { userRepository, domain, createUserOnDomain, someoneElse } = setupDomain();
await createUserOnDomain({ createdAt: subDays(new Date(), 45) }, domain.toUpperCase());

await expect(userRepository.hasEstablishedUserWithEmailDomain(domain, 30, someoneElse)).resolves.toBe(true);
});
});

function setupDomain() {
const userRepository = container.resolve(UserRepository);
const domain = `${faker.string.alphanumeric(16).toLowerCase()}.com`;

async function createUserOnDomain(overrides: { createdAt: Date }, onDomain = domain) {
return await userRepository.create({
userId: faker.string.uuid(),
email: `${faker.string.alphanumeric(10)}@${onDomain}`,
...overrides
});
}

return { userRepository, domain, createUserOnDomain, someoneElse: faker.string.uuid() };
}

function setup() {
const userRepository = container.resolve(UserRepository);
const createdUserIds: string[] = [];
Expand Down
Loading