Skip to content
Open
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
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