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
18 changes: 18 additions & 0 deletions .env.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,24 @@
"defaultValue": 50,
"required": false
},
{
"name": "SPACES_INVITE_EXPIRY_SECONDS",
"description": "Time until a Space invite expires, in seconds",
"defaultValue": 604800,
"required": false
},
{
"name": "SPACES_RESEND_INVITE_RATE_LIMIT_MAX",
"description": "Maximum resend invite requests per time window",
"defaultValue": 50,
"required": false
},
{
"name": "SPACES_RESEND_INVITE_RATE_LIMIT_WINDOW_SECONDS",
"description": "Time window for resend invite rate limiting",
"defaultValue": 600,
"required": false
},
{
"name": "SPACES_MAX_SAFES_PER_SPACE",
"description": "Maximum number of Safes per Space",
Expand Down
24 changes: 24 additions & 0 deletions migrations/1777000000000-add-member-invite-expires-at.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: FSL-1.1-MIT
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddMemberInviteExpiresAt1777000000000
implements MigrationInterface
{
name = 'AddMemberInviteExpiresAt1777000000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "members" ADD COLUMN "invite_expires_at" timestamp with time zone`,
);
await queryRunner.query(
// MemberStatus enum values at migration time: 0 = INVITED, 2 = DECLINED.
`UPDATE "members" SET "invite_expires_at" = "created_at" + interval '7 days' WHERE "status" IN (0, 2)`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "members" DROP COLUMN "invite_expires_at"`,
);
}
}
5 changes: 5 additions & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ export default (): ReturnType<typeof configuration> => ({
maxSafesPerSpace: faker.number.int({ min: 5, max: 10 }),
maxSpaceCreationsPerUser: faker.number.int({ min: 100, max: 200 }),
maxInvites: faker.number.int({ min: 5, max: 10 }),
inviteExpirySeconds: faker.number.int({ min: 100, max: 200 }),
rateLimit: {
creation: {
max: faker.number.int({ min: 100, max: 200 }),
Expand All @@ -441,6 +442,10 @@ export default (): ReturnType<typeof configuration> => ({
max: faker.number.int({ min: 100, max: 200 }),
windowSeconds: faker.number.int({ min: 100, max: 200 }),
},
resendInvite: {
max: faker.number.int({ min: 100, max: 200 }),
windowSeconds: faker.number.int({ min: 100, max: 200 }),
},
},
},
staking: {
Expand Down
15 changes: 15 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,10 @@ export default () => ({
10,
),
maxInvites: Number.parseInt(process.env.SPACES_MAX_INVITES ?? `${50}`, 10),
inviteExpirySeconds: Number.parseInt(
process.env.SPACES_INVITE_EXPIRY_SECONDS ?? `${7 * 24 * 60 * 60}`,
10,
),
rateLimit: {
creation: {
max: Number.parseInt(process.env.SPACES_RATE_LIMIT_MAX ?? `${10}`, 10),
Expand All @@ -773,6 +777,17 @@ export default () => ({
10,
),
},
resendInvite: {
max: Number.parseInt(
process.env.SPACES_RESEND_INVITE_RATE_LIMIT_MAX ?? `${50}`,
10,
),
windowSeconds: Number.parseInt(
process.env.SPACES_RESEND_INVITE_RATE_LIMIT_WINDOW_SECONDS ??
`${600}`,
10,
),
},
},
},
staking: {
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/schemas/configuration.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const RootConfigurationSchema = z
.optional(),
AUTH0_JWKS_COOLDOWN_MILLISECONDS: z.coerce.number().int().min(1).optional(),
AUTH_STATE_TTL_MILLISECONDS: z.coerce.number().int().min(1).optional(),
SPACES_INVITE_EXPIRY_SECONDS: z.coerce.number().int().min(1).optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_KMS_ENCRYPTION_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ describe('SpacesRepository', () => {
name: expect.any(String),
alias: null,
invitedBy: null,
inviteExpiresAt: null,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
user: {
Expand Down Expand Up @@ -327,6 +328,7 @@ describe('SpacesRepository', () => {
name: `${spaceName} creator`,
alias: null,
invitedBy: null,
inviteExpiresAt: null,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
user: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: FSL-1.1-MIT
import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';
import { ResendInviteDtoSchema } from '@/modules/spaces/routes/entities/resend-invite.dto.entity';

describe('ResendInviteDtoSchema', () => {
it('should accept an address-only payload', () => {
const result = ResendInviteDtoSchema.safeParse({
address: getAddress(faker.finance.ethereumAddress()),
});

expect(result.success).toBe(true);
});

it('should accept an email-only payload', () => {
const result = ResendInviteDtoSchema.safeParse({
email: faker.internet.email().toLowerCase(),
});

expect(result.success).toBe(true);
});

it('should reject a payload that has both address and email', () => {
const result = ResendInviteDtoSchema.safeParse({
address: getAddress(faker.finance.ethereumAddress()),
email: faker.internet.email().toLowerCase(),
});

expect(result.success).toBe(false);
});

it('should reject an empty payload', () => {
const result = ResendInviteDtoSchema.safeParse({});

expect(result.success).toBe(false);
});

it('should reject a non-address string in the address branch', () => {
const result = ResendInviteDtoSchema.safeParse({
address: 'not-an-address',
});

expect(result.success).toBe(false);
});

it('should reject a malformed email', () => {
const result = ResendInviteDtoSchema.safeParse({ email: 'not-an-email' });

expect(result.success).toBe(false);
});

it('should reject an email longer than 255 characters', () => {
const localPart = 'a'.repeat(250);
const result = ResendInviteDtoSchema.safeParse({
email: `${localPart}@example.com`,
});

expect(result.success).toBe(false);
});
});
3 changes: 3 additions & 0 deletions src/modules/spaces/routes/entities/get-space.dto.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class SpaceMemberDto {
})
public status!: Member['status'];

@ApiProperty({ type: Date, nullable: true })
public inviteExpiresAt!: Member['inviteExpiresAt'];

@ApiProperty({ type: UserDto })
public user!: Partial<UserDto>;
}
Expand Down
7 changes: 5 additions & 2 deletions src/modules/spaces/routes/entities/invitation.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import {
import type { User } from '@/modules/users/domain/entities/user.entity';

export class Invitation {
@ApiProperty({ type: Number })
userId!: User['id'];
@ApiPropertyOptional({ type: Number })
userId?: User['id'];

@ApiProperty({ type: String })
name!: Member['name'];
Expand All @@ -27,4 +27,7 @@ export class Invitation {

@ApiPropertyOptional({ type: String, nullable: true })
invitedBy!: Member['invitedBy'];

@ApiProperty({ type: Date })
inviteExpiresAt!: Member['inviteExpiresAt'];
}
38 changes: 25 additions & 13 deletions src/modules/spaces/routes/entities/invite-users.dto.entity.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,44 @@
// SPDX-License-Identifier: FSL-1.1-MIT
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import type { Address } from 'viem';
import { z } from 'zod';
import {
NAME_MAX_LENGTH,
NAME_MIN_LENGTH,
NameSchema,
} from '@/domain/common/schemas/name.schema';
import { getStringEnumKeys } from '@/domain/common/utils/enum';
import { MemberRole } from '@/modules/users/domain/entities/member.entity';
import { AddressSchema } from '@/validation/entities/schemas/address.schema';

const InviteUserDtoSchema = z
.array(
z.object({
address: AddressSchema,
role: z.enum(getStringEnumKeys(MemberRole)),
name: z.string().max(255),
}),
)
.min(1);
const SharedInviteFields = {
role: z.enum(getStringEnumKeys(MemberRole)),
name: NameSchema,
};

const InviteUserSchema = z.union([
z.object({ address: AddressSchema, ...SharedInviteFields }).strict(),
z.object({ email: z.email().max(255), ...SharedInviteFields }).strict(),
]);

const InviteUserDtoSchema = z.array(InviteUserSchema).min(1);

export const InviteUsersDtoSchema = z.object({
users: InviteUserDtoSchema,
});

export class InviteUserDto {
@ApiProperty()
public readonly address!: Address;
@ApiPropertyOptional({
description:
'Wallet address to invite. Provide either address or email, but not both.',
})
public readonly address?: Address;

@ApiPropertyOptional({
description:
'Email address to invite. Provide either email or address, but not both.',
})
public readonly email?: string;

@ApiProperty({
type: String,
Expand All @@ -41,7 +53,7 @@ export class InviteUserDto {
public readonly role!: keyof typeof MemberRole;
}

export class InviteUsersDto implements z.infer<typeof InviteUsersDtoSchema> {
export class InviteUsersDto {
@ApiProperty({
type: InviteUserDto,
isArray: true,
Expand Down
3 changes: 3 additions & 0 deletions src/modules/spaces/routes/entities/members.dto.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export class MemberDto {
@ApiProperty()
updatedAt!: Date;

@ApiProperty({ type: Date, nullable: true })
inviteExpiresAt!: DomainMember['inviteExpiresAt'];

@ApiProperty({ type: MemberUser })
user!: MemberUser;
}
Expand Down
18 changes: 18 additions & 0 deletions src/modules/spaces/routes/entities/resend-invite.dto.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: FSL-1.1-MIT
import { ApiPropertyOptional } from '@nestjs/swagger';
import type { Address } from 'viem';
import { z } from 'zod';
import { AddressSchema } from '@/validation/entities/schemas/address.schema';

export const ResendInviteDtoSchema = z.union([
z.object({ address: AddressSchema }).strict(),
z.object({ email: z.email().max(255) }).strict(),
]);

export class ResendInviteDto {
@ApiPropertyOptional()
public readonly address?: Address;

@ApiPropertyOptional()
public readonly email?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: FSL-1.1-MIT
import { Inject, Injectable } from '@nestjs/common';
import { IConfigurationService } from '@/config/configuration.service.interface';
import {
CacheService,
type ICacheService,
} from '@/datasources/cache/cache.service.interface';
import {
type ILoggingService,
LoggingService,
} from '@/logging/logging.interface';
import { RateLimitGuard } from '@/routes/common/guards/rate-limit.guard';

@Injectable()
export class SpacesResendInviteRateLimitGuard extends RateLimitGuard {
constructor(
@Inject(IConfigurationService)
readonly configurationService: IConfigurationService,
@Inject(CacheService) cacheService: ICacheService,
@Inject(LoggingService) loggingService: ILoggingService,
) {
const rateLimits = {
max: configurationService.getOrThrow<number>(
'spaces.rateLimit.resendInvite.max',
),
windowSeconds: configurationService.getOrThrow<number>(
'spaces.rateLimit.resendInvite.windowSeconds',
),
};
super(cacheService, loggingService, rateLimits);
}
}
Loading
Loading