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
368 changes: 52 additions & 316 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
"private": true,
"license": "MIT",
"scripts": {
"prebuild": "rimraf dist",
"prebuild": "rimraf dist tsconfig.tsbuildinfo",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"prestart": "rimraf dist tsconfig.tsbuildinfo",
"start": "nest start",
"prestart:dev": "rimraf dist tsconfig.tsbuildinfo",
"start:dev": "nest start --watch",
"prestart:debug": "rimraf dist tsconfig.tsbuildinfo",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
Expand Down Expand Up @@ -41,6 +44,7 @@
},
"dependencies": {
"@apollo/server": "^5.5.1",
"@keyv/redis": "^5.1.6",
"@nestjs-modules/mailer": "^2.3.4",
"@nestjs/apollo": "^13.4.2",
"@nestjs/bullmq": "^11.0.4",
Expand All @@ -62,7 +66,6 @@
"bcrypt": "^6.0.0",
"bullmq": "^5.76.3",
"cache-manager": "^7.2.8",
"cache-manager-redis-store": "^3.0.1",
"canvas": "^3.2.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
Expand All @@ -71,6 +74,7 @@
"ethers": "^6.16.0",
"express": "^5.2.1",
"graphql": "^16.13.2",
"graphql-query-complexity": "^2.0.0",
"graphql-subscriptions": "^3.0.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2",
Expand Down
3 changes: 3 additions & 0 deletions prisma/migrations/20260726120000_add_user_tier/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE TYPE "UserTier" AS ENUM ('FREE', 'PREMIUM', 'ENTERPRISE');

ALTER TABLE "users" ADD COLUMN "tier" "UserTier" NOT NULL DEFAULT 'FREE';
7 changes: 7 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ enum UserRole {
ADMIN
}

enum UserTier {
FREE
PREMIUM
ENTERPRISE
}

enum TokenType {
ACCESS
REFRESH
Expand Down Expand Up @@ -179,6 +185,7 @@ model User {
twoFactorBackupCodes String[] @default([]) @map("two_factor_backup_codes")
// NOTE: added to satisfy code references; CI-prone strict mode workaround
role UserRole @default(USER) @map("role")
tier UserTier @default(FREE) @map("tier")
avatar String?
pendingEmail String? @map("pending_email")
emailVerificationToken String? @map("email_verification_token")
Expand Down
14 changes: 13 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,19 @@ import { CleanupService } from './database/cleanup.service';
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),

GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
// playground removed: Apollo Server v5 defaults to Apollo Sandbox (uses @apollo/server@^5 peer)
subscriptions: {
'graphql-ws': true,
},
// Exposes the raw request on the Apollo context so plugins (e.g. the
// query-complexity plugin) and field-level auth guards can read/set
// request-scoped data such as request.authUser.
context: ({ req }) => ({ req }),
}),
ScheduleModule.forRoot(),
CacheModuleConfig,
AnalyticsModule,
Expand Down
133 changes: 133 additions & 0 deletions src/auth/auth-tier.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { AuthService } from './auth.service';
import { PrismaService } from '../database/prisma.service';
import { UsersService } from '../users/users.service';
import { SessionsService } from '../sessions/sessions.service';
import { EmailService } from '../email/email.service';
import { LoginRateLimitService } from './login-rate-limit.service';
import { FraudService } from '../fraud/fraud.service';

describe('AuthService - tier propagation', () => {
let service: AuthService;

const sessionsService = {
createSession: jest.fn().mockResolvedValue(undefined),
};

const prisma = {
user: { findUnique: jest.fn(), update: jest.fn().mockResolvedValue(undefined) },
apiKey: { findUnique: jest.fn(), update: jest.fn().mockResolvedValue(undefined) },
blacklistedToken: { findUnique: jest.fn().mockResolvedValue(null) },
};

const configService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
JWT_SECRET: 'test-secret',
JWT_REFRESH_SECRET: 'test-refresh-secret',
JWT_ACCESS_EXPIRES_IN: '15m',
JWT_REFRESH_EXPIRES_IN: '7d',
BCRYPT_ROUNDS: '10',
};
return config[key];
}),
};

beforeEach(async () => {
jest.clearAllMocks();
prisma.user.update.mockResolvedValue(undefined);
prisma.apiKey.update.mockResolvedValue(undefined);
prisma.blacklistedToken.findUnique.mockResolvedValue(null);

const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: prisma },
{ provide: UsersService, useValue: {} },
{ provide: SessionsService, useValue: sessionsService },
{ provide: EmailService, useValue: {} },
{ provide: LoginRateLimitService, useValue: {} },
{ provide: FraudService, useValue: {} },
{ provide: ConfigService, useValue: configService },
],
}).compile();

service = module.get<AuthService>(AuthService);
});

describe('issueTokenPair', () => {
it('includes tier in both the access and refresh token claims', async () => {
const user = {
id: 'user-1',
email: 'user@test.com',
role: 'AGENT',
tier: 'PREMIUM',
} as any;

const tokens = await service.issueTokenPair(user);

const accessClaims = jwt.decode(tokens.accessToken) as any;
const refreshClaims = jwt.decode(tokens.refreshToken) as any;

expect(accessClaims.tier).toBe('PREMIUM');
expect(accessClaims.role).toBe('AGENT');
expect(refreshClaims.tier).toBe('PREMIUM');
});
});

describe('validateAccessToken', () => {
it('returns the tier read fresh from the database, not from the token', async () => {
const token = jwt.sign(
{
sub: 'user-1',
email: 'user@test.com',
role: 'FREE',
tier: 'FREE',
type: 'access',
jti: 'jti-1',
},
'test-secret',
{ expiresIn: '15m', issuer: 'PropChain' },
);

// DB now reports a different (upgraded) tier than what was baked into the token
prisma.user.findUnique.mockResolvedValue({
email: 'user@test.com',
role: 'USER',
tier: 'ENTERPRISE',
lastActivityAt: new Date(),
});

const result = await service.validateAccessToken(token);

expect(result.tier).toBe('ENTERPRISE');
expect(result.sub).toBe('user-1');
});
});

describe('validateApiKey', () => {
it('surfaces the owning user tier on the api-key payload', async () => {
prisma.apiKey.findUnique.mockResolvedValue({
id: 'key-1',
userId: 'user-1',
revokedAt: null,
expiresAt: null,
permissions: ['read'],
user: {
email: 'user@test.com',
role: 'AGENT',
tier: 'ENTERPRISE',
isBlocked: false,
},
});

const result = await service.validateApiKey('raw-api-key-value');

expect(result.tier).toBe('ENTERPRISE');
expect(result.type).toBe('api-key');
expect(result.apiKeyId).toBe('key-1');
});
});
});
2 changes: 2 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RateLimitGuard } from './guards/rate-limit.guard';
import { RateLimitHeadersInterceptor } from './interceptors/rate-limit-headers.interceptor';
import { RateLimitAdminController } from './controllers/rate-limit-admin.controller';
import { FraudModule } from '../fraud/fraud.module';
import { GraphqlComplexityPlugin } from './graphql-complexity.plugin';

@Module({
imports: [PrismaModule, UsersModule, SessionsModule, EmailModule, FraudModule, PassportModule],
Expand All @@ -34,6 +35,7 @@ import { FraudModule } from '../fraud/fraud.module';
RateLimitGuard,
RateLimitHeadersInterceptor,
GoogleStrategy,
GraphqlComplexityPlugin,
],
exports: [
AuthService,
Expand Down
8 changes: 7 additions & 1 deletion src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { AuthUserPayload } from './types/auth-user.type';
import { GoogleProfile } from './strategies/google.strategy';

import { LoginRateLimitService } from './login-rate-limit.service';
import { UserRole } from '../types/prisma.types';
import { UserRole, UserTier } from '../types/prisma.types';
import { FraudService } from '../fraud/fraud.service';
import { ApiKeyAnalyticsService } from './api-key-analytics.service';

Expand All @@ -58,6 +58,7 @@ type JwtPayload = {
sub: string;
email: string;
role: UserRole;
tier: UserTier;
type: 'access' | 'refresh';
jti: string;
family?: string;
Expand Down Expand Up @@ -1161,6 +1162,7 @@ export class AuthService {
select: {
email: true,
role: true,
tier: true,
lastActivityAt: true,
},
});
Expand Down Expand Up @@ -1189,6 +1191,7 @@ export class AuthService {
sub: payload.sub,
email: user.email,
role: user.role,
tier: user.tier,
type: 'access',
jti: payload.jti,
};
Expand Down Expand Up @@ -1232,6 +1235,7 @@ export class AuthService {
sub: apiKey.userId,
email: apiKey.user.email,
role: apiKey.user.role as UserRole,
tier: apiKey.user.tier as UserTier,
type: 'api-key',
apiKeyId: apiKey.id,
apiKeyPermissions: apiKey.permissions,
Expand All @@ -1253,6 +1257,7 @@ export class AuthService {
sub: user.id,
email: user.email,
role: user.role as UserRole,
tier: user.tier as UserTier,
type: 'access',
jti: accessJti,
family: family,
Expand All @@ -1266,6 +1271,7 @@ export class AuthService {
sub: user.id,
email: user.email,
role: user.role as UserRole,
tier: user.tier as UserTier,
type: 'refresh',
jti: refreshJti,
family: family,
Expand Down
93 changes: 93 additions & 0 deletions src/auth/graphql-complexity.plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { buildSchema, parse } from 'graphql';
import { GraphqlComplexityPlugin } from './graphql-complexity.plugin';

describe('GraphqlComplexityPlugin', () => {
const schema = buildSchema(`
type Item {
a: String
b: String
c: String
}
type Query {
items: [Item]
}
`);

function makeAuthService(validateAccessToken: jest.Mock) {
return { validateAccessToken } as any;
}

function buildQuery(aliasCount: number) {
const aliases = Array.from({ length: aliasCount }, (_, i) => `i${i}: items { a b c }`).join(
'\n',
);
return parse(`query { ${aliases} }`);
}

it('allows a query within the anonymous tier limit (no auth header)', async () => {
const plugin = new GraphqlComplexityPlugin(makeAuthService(jest.fn()));
const listener = await plugin.requestDidStart();

await expect(
listener.didResolveOperation!({
request: { operationName: undefined, variables: {} },
document: buildQuery(1),
schema,
contextValue: { req: { headers: {} } },
} as any),
).resolves.toBeUndefined();
});

it('rejects a query over the anonymous tier limit', async () => {
const plugin = new GraphqlComplexityPlugin(makeAuthService(jest.fn()));
const listener = await plugin.requestDidStart();

await expect(
listener.didResolveOperation!({
request: { operationName: undefined, variables: {} },
document: buildQuery(20),
schema,
contextValue: { req: { headers: {} } },
} as any),
).rejects.toThrow(/exceeds the maximum allowed complexity/);
});

it('grants a higher limit for an authenticated premium-tier request', async () => {
const validateAccessToken = jest.fn().mockResolvedValue({
sub: 'user-1',
tier: 'PREMIUM',
type: 'access',
});
const plugin = new GraphqlComplexityPlugin(makeAuthService(validateAccessToken));
const listener = await plugin.requestDidStart();

const req: any = { headers: { authorization: 'Bearer valid-token' } };

await expect(
listener.didResolveOperation!({
request: { operationName: undefined, variables: {} },
document: buildQuery(20),
schema,
contextValue: { req },
} as any),
).resolves.toBeUndefined();

expect(validateAccessToken).toHaveBeenCalledWith('valid-token');
expect(req.authUser.tier).toBe('PREMIUM');
});

it('falls back to the anonymous limit when the token is invalid', async () => {
const validateAccessToken = jest.fn().mockRejectedValue(new Error('bad token'));
const plugin = new GraphqlComplexityPlugin(makeAuthService(validateAccessToken));
const listener = await plugin.requestDidStart();

await expect(
listener.didResolveOperation!({
request: { operationName: undefined, variables: {} },
document: buildQuery(20),
schema,
contextValue: { req: { headers: { authorization: 'Bearer garbage' } } },
} as any),
).rejects.toThrow(/exceeds the maximum allowed complexity/);
});
});
Loading
Loading