Skip to content
Merged
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
11 changes: 11 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ export class AuthController {
);
}

@Post('confirm-auto-renewal')
@UseGuards(JwtOrApiKeyGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Confirm intent to keep auto-renewal active (free tier)' })
@ApiResponse({ status: 201, description: 'Auto-renewal confirmation timestamp reset' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@RateLimitCategoryDecorator(RateLimitCategory.AUTHENTICATED_WRITE)
confirmAutoRenewal(@Req() req: RequestWithUser) {
return this.authService.confirmAutoRenewal(req.user.userId);
}

@Delete('api-keys/:id')
@UseGuards(JwtOrApiKeyGuard)
@ApiBearerAuth()
Expand Down
14 changes: 14 additions & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ export class AuthService {
notificationPreferences: user.notificationPreferences,
createdAt: user.createdAt.toISOString(),
plan,
autoRenewalConfirmedAt:
user.autoRenewalConfirmedAt?.toISOString() ?? null,
resourceCounts: {
domains: domainCount,
certificates: certCount,
Expand Down Expand Up @@ -311,6 +313,17 @@ export class AuthService {
return this.getFullProfile(userId);
}

async confirmAutoRenewal(userId: string): Promise<{ confirmedAt: string }> {
const user = await this.userRepo.findOneBy({ id: userId });
if (!user) {
throw new NotFoundException('User not found');
}
user.autoRenewalConfirmedAt = new Date();
user.autoRenewalReminderSentAt = null;
await this.userRepo.save(user);
return { confirmedAt: user.autoRenewalConfirmedAt.toISOString() };
}

private async ensureUserExists(authUser: {
sub: string;
preferred_username: string;
Expand All @@ -324,6 +337,7 @@ export class AuthService {
username: authUser.preferred_username,
email: authUser.email,
groups: authUser.groups || [],
autoRenewalConfirmedAt: new Date(),
});
await this.userRepo.save(user);
}
Expand Down
28 changes: 28 additions & 0 deletions backend/src/certs/tls/services/cert-monitor.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { CertMonitorService } from './cert-monitor.service';
import { TlsService } from '../tls.service';
import { TlsCrt } from '../entities/tls-crt.entity';
import { User } from '../../../users/entities/user.entity';
import { CertStatus } from '@krakenkey/shared';
import { MetricsService } from '../../../metrics/metrics.service';
import { EmailService } from '../../../notifications/email.service';
Expand All @@ -11,11 +12,28 @@ import { BillingService } from '../../../billing/billing.service';
describe('CertMonitorService', () => {
let service: CertMonitorService;
let mockRepository: Record<string, jest.Mock>;
let mockUserRepository: Record<string, jest.Mock>;
let mockTlsService: Record<string, jest.Mock>;

const mockUser: User = {
id: 'user-123',
username: 'testuser',
email: 'test@example.com',
groups: [],
displayName: null,
notificationPreferences: {},
createdAt: new Date(),
autoRenewalConfirmedAt: new Date(), // freshly confirmed — not lapsed
autoRenewalReminderSentAt: null,
apiKeys: [],
domains: [],
tlsCrts: [],
};

const expiringCert = {
id: 1,
userId: 'user-123',
user: mockUser,
status: 'issued',
autoRenew: true,
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // 3 days from now (within free tier 5-day window)
Expand All @@ -27,6 +45,11 @@ describe('CertMonitorService', () => {
count: jest.fn().mockResolvedValue(0),
};

mockUserRepository = {
findOneBy: jest.fn().mockResolvedValue(mockUser),
save: jest.fn().mockResolvedValue(mockUser),
};

mockTlsService = {
renewInternal: jest.fn(),
};
Expand All @@ -38,6 +61,10 @@ describe('CertMonitorService', () => {
provide: getRepositoryToken(TlsCrt),
useValue: mockRepository,
},
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
{
provide: TlsService,
useValue: mockTlsService,
Expand All @@ -53,6 +80,7 @@ describe('CertMonitorService', () => {
provide: EmailService,
useValue: {
sendCertExpiryWarning: jest.fn(),
sendAutoRenewalPaused: jest.fn(),
},
},
{
Expand Down
44 changes: 43 additions & 1 deletion backend/src/certs/tls/services/cert-monitor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MetricsService } from '../../../metrics/metrics.service';
import { EmailService } from '../../../notifications/email.service';
import { BillingService } from '../../../billing/billing.service';
import { PLAN_LIMITS } from '../../../billing/constants/plan-limits';
import { User } from '../../../users/entities/user.entity';

@Injectable()
export class CertMonitorService {
Expand All @@ -18,6 +19,8 @@ export class CertMonitorService {
constructor(
@InjectRepository(TlsCrt)
private readonly tlsCrtRepository: Repository<TlsCrt>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly tlsService: TlsService,
private readonly metricsService: MetricsService,
private readonly emailService: EmailService,
Expand Down Expand Up @@ -66,8 +69,9 @@ export class CertMonitorService {
`Certificate expiry check: ${expiring.length} certificate(s) expiring within 30 days`,
);

// Cache plan lookups to avoid redundant calls for certs owned by the same user
// Cache plan lookups and lapsed status to avoid redundant calls per user
const planCache = new Map<string, SubscriptionPlan>();
const lapsedCache = new Map<string, boolean>();

for (const cert of expiring) {
if (!cert.expiresAt) continue;
Expand All @@ -90,6 +94,26 @@ export class CertMonitorService {
// Skip certs outside this user's renewal window
if (daysUntilExpiry > windowDays) continue;

// Free-tier auto-renewal confirmation check
if (userPlan === 'free') {
let lapsed = lapsedCache.get(cert.userId);
if (lapsed === undefined) {
const user =
cert.user ??
(await this.userRepository.findOneBy({ id: cert.userId }));
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
lapsed =
!user?.autoRenewalConfirmedAt ||
user.autoRenewalConfirmedAt < sixMonthsAgo;
lapsedCache.set(cert.userId, lapsed);
if (lapsed && user) {
await this.maybeSendRenewalPausedReminder(user);
}
}
if (lapsed) continue;
}

if (cert.user) {
const commonName =
(cert.parsedCsr?.subject?.find((a) => a.shortName === 'CN')
Expand Down Expand Up @@ -117,4 +141,22 @@ export class CertMonitorService {
}
}
}

private async maybeSendRenewalPausedReminder(user: User): Promise<void> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
if (
user.autoRenewalReminderSentAt &&
user.autoRenewalReminderSentAt > thirtyDaysAgo
) {
return;
}
await this.emailService.sendAutoRenewalPaused({
userId: user.id,
username: user.username,
email: user.email,
});
user.autoRenewalReminderSentAt = new Date();
await this.userRepository.save(user);
}
}
3 changes: 2 additions & 1 deletion backend/src/certs/tls/tls.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TlsController } from './tls.controller';
import { CsrUtilService } from './util/csr-util.service';
import { CertUtilService } from './util/cert-util.service';
import { TlsCrt } from './entities/tls-crt.entity';
import { User } from '../../users/entities/user.entity';
import { BullModule } from '@nestjs/bullmq';
import { CertIssuerConsumer } from './processors/tls-crt-issuer.processor';
import { AcmeIssuerStrategy } from './strategies/acme-issuer.strategy';
Expand All @@ -18,7 +19,7 @@ import { CertMonitorService } from './services/cert-monitor.service';

@Module({
imports: [
TypeOrmModule.forFeature([TlsCrt]),
TypeOrmModule.forFeature([TlsCrt, User]),
BullModule.registerQueue({
name: 'tlsCertIssuance',
}),
Expand Down
2 changes: 2 additions & 0 deletions backend/src/domains/services/domain-monitor.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe('DomainMonitorService', () => {
apiKeys: [],
tlsCrts: [],
notificationPreferences: {},
autoRenewalConfirmedAt: new Date(),
autoRenewalReminderSentAt: null,
},
createdAt: new Date(),
updatedAt: new Date(),
Expand Down
21 changes: 21 additions & 0 deletions backend/src/migrations/1772900000000-AddAutoRenewalConfirmation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddAutoRenewalConfirmation1772900000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "user"
ADD COLUMN IF NOT EXISTS "autoRenewalConfirmedAt" TIMESTAMP DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS "autoRenewalReminderSentAt" TIMESTAMP DEFAULT NULL
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "user"
DROP COLUMN IF EXISTS "autoRenewalConfirmedAt",
DROP COLUMN IF EXISTS "autoRenewalReminderSentAt"
`);
}
}
19 changes: 19 additions & 0 deletions backend/src/notifications/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
certRevokedTemplate,
domainVerificationFailedTemplate,
planLimitReachedTemplate,
autoRenewalPausedTemplate,
} from './templates';
import { User } from '../users/entities/user.entity';
import { NotificationType } from '@krakenkey/shared';
Expand Down Expand Up @@ -45,6 +46,12 @@ export interface PlanLimitReachedContext {
limit: number;
}

export interface AutoRenewalPausedContext {
userId?: string;
username: string;
email: string;
}

@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
Expand Down Expand Up @@ -187,6 +194,18 @@ export class EmailService {
);
}

async sendAutoRenewalPaused(ctx: AutoRenewalPausedContext): Promise<void> {
if (
!(await this.shouldSend(ctx.userId, NotificationType.AUTO_RENEWAL_PAUSED))
)
return;
await this.send(
ctx.email,
'KrakenKey auto-renewal paused — action required',
autoRenewalPausedTemplate(ctx),
);
}

async sendPlanLimitReached(ctx: PlanLimitReachedContext): Promise<void> {
if (
!(await this.shouldSend(
Expand Down
22 changes: 22 additions & 0 deletions backend/src/notifications/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,25 @@ export function domainVerificationFailedTemplate(
].join(''),
);
}

export function autoRenewalPausedTemplate(ctx: {
username: string;
}): string {
return layout(
'Auto-renewal paused — action required',
[
p(
`Hi ${ctx.username}, your KrakenKey auto-renewal has been paused because 6 months have passed without re-confirmation.`,
),
p(
'Your certificates are safe — they will not be deleted or revoked. Auto-renewal resumes as soon as you confirm.',
),
`<div style="margin:24px 0;text-align:center">
<a href="https://app.krakenkey.io/dashboard" style="display:inline-block;padding:10px 24px;background:#06b6d4;color:#09090b;font-size:14px;font-weight:600;border-radius:6px;text-decoration:none">Keep auto-renewal active</a>
</div>`,
p(
'If you no longer need auto-renewal, you can ignore this email. You can always re-enable it from your dashboard.',
),
].join(''),
);
}
6 changes: 6 additions & 0 deletions backend/src/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export class User {
@CreateDateColumn()
createdAt: Date;

@Column({ type: 'timestamp', nullable: true, default: () => 'CURRENT_TIMESTAMP' })
autoRenewalConfirmedAt: Date | null;

@Column({ type: 'timestamp', nullable: true, default: null })
autoRenewalReminderSentAt: Date | null;

@OneToMany(() => UserApiKey, (apiKey) => apiKey.user)
apiKeys: UserApiKey[];

Expand Down
Loading
Loading