Skip to content

Commit 1ea6c4b

Browse files
authored
fix: remove custom domains after plan expiry (#257)
1 parent 7030bf7 commit 1ea6c4b

6 files changed

Lines changed: 163 additions & 5 deletions

File tree

be/apps/core/src/modules/infrastructure/cloudflare/cloudflare-custom-hostname.service.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,22 @@ describe('cloudflareCustomHostnameService', () => {
129129

130130
await expect(service.create('photos.example.com')).rejects.toThrow('Zone does not have a fallback origin set.')
131131
})
132+
133+
it('treats an already deleted hostname as a successful idempotent deletion', async () => {
134+
fetchMock.mockResolvedValueOnce(
135+
new Response(
136+
JSON.stringify({
137+
success: false,
138+
errors: [{ code: 1436, message: 'The custom hostname was not found.' }],
139+
}),
140+
{ status: 404, headers: { 'content-type': 'application/json' } },
141+
),
142+
)
143+
144+
await expect(service.delete('missing-hostname-id')).resolves.toBeUndefined()
145+
146+
const [url, request] = fetchMock.mock.calls[0] as [string, RequestInit]
147+
expect(url).toContain('/custom_hostnames/missing-hostname-id')
148+
expect(request.method).toBe('DELETE')
149+
})
132150
})

be/apps/core/src/modules/infrastructure/cloudflare/cloudflare-custom-hostname.service.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ export class CloudflareCustomHostnameService {
9595
}
9696

9797
async delete(customHostnameId: string): Promise<void> {
98-
await this.request<unknown>(`/custom_hostnames/${customHostnameId}`, { method: 'DELETE' })
98+
await this.request<unknown>(`/custom_hostnames/${customHostnameId}`, {
99+
method: 'DELETE',
100+
ignoreNotFound: true,
101+
})
99102
}
100103

101104
private getConfig(): CloudflareCustomHostnameConfig {
@@ -130,7 +133,11 @@ export class CloudflareCustomHostnameService {
130133

131134
private async request<T>(
132135
path: string,
133-
options: { method?: 'DELETE' | 'GET' | 'PATCH' | 'POST', body?: Record<string, unknown> } = {},
136+
options: {
137+
method?: 'DELETE' | 'GET' | 'PATCH' | 'POST'
138+
body?: Record<string, unknown>
139+
ignoreNotFound?: boolean
140+
} = {},
134141
): Promise<T> {
135142
const config = this.getConfig()
136143
const method = options.method ?? 'GET'
@@ -144,6 +151,10 @@ export class CloudflareCustomHostnameService {
144151
signal: AbortSignal.timeout(10_000),
145152
})
146153

154+
if (options.ignoreNotFound && response.status === 404) {
155+
return undefined as T
156+
}
157+
147158
let payload: CloudflareApiResponse<T> | null = null
148159
try {
149160
payload = (await response.json()) as CloudflareApiResponse<T>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import { AuthProvider } from './auth.provider'
4+
5+
interface CreemWebhookInvoker {
6+
handleCreemWebhook: (params: {
7+
event: string
8+
metadata?: Record<string, unknown> | null
9+
status?: string | null
10+
forceRevoke?: boolean
11+
}) => Promise<void>
12+
}
13+
14+
function createProvider() {
15+
const billingPlanService = {
16+
updateTenantPlan: vi.fn().mockResolvedValue(undefined),
17+
}
18+
const storagePlanService = {
19+
updateTenantPlan: vi.fn().mockResolvedValue(undefined),
20+
}
21+
const tenantDomainService = {
22+
deleteDomainsForTenant: vi.fn().mockResolvedValue(1),
23+
}
24+
const provider = new AuthProvider(
25+
{} as never,
26+
{} as never,
27+
{} as never,
28+
{} as never,
29+
{} as never,
30+
{} as never,
31+
billingPlanService as never,
32+
storagePlanService as never,
33+
tenantDomainService as never,
34+
)
35+
36+
return {
37+
billingPlanService,
38+
provider: provider as unknown as CreemWebhookInvoker,
39+
tenantDomainService,
40+
}
41+
}
42+
43+
describe('authProvider billing revocation', () => {
44+
it('deletes custom domains after an expired subscription downgrades the tenant', async () => {
45+
const { billingPlanService, provider, tenantDomainService } = createProvider()
46+
47+
await provider.handleCreemWebhook({
48+
event: 'subscription.expired',
49+
metadata: { planId: 'pro', tenantId: 'tenant-1' },
50+
status: 'expired',
51+
forceRevoke: true,
52+
})
53+
54+
expect(billingPlanService.updateTenantPlan).toHaveBeenCalledWith('tenant-1', 'free')
55+
expect(tenantDomainService.deleteDomainsForTenant).toHaveBeenCalledWith('tenant-1')
56+
})
57+
58+
it('propagates domain cleanup failures so the expired webhook can be retried', async () => {
59+
const { provider, tenantDomainService } = createProvider()
60+
tenantDomainService.deleteDomainsForTenant.mockRejectedValueOnce(new Error('Cloudflare unavailable'))
61+
62+
await expect(
63+
provider.handleCreemWebhook({
64+
event: 'subscription.expired',
65+
metadata: { planId: 'pro', tenantId: 'tenant-1' },
66+
status: 'expired',
67+
forceRevoke: true,
68+
}),
69+
).rejects.toThrow('Cloudflare unavailable')
70+
})
71+
})

be/apps/core/src/modules/platform/auth/auth.provider.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { BILLING_PLAN_IDS } from '@core/modules/platform/billing/billing-plan.co
1616
import { BillingPlanService } from '@core/modules/platform/billing/billing-plan.service'
1717
import type { BillingPlanId } from '@core/modules/platform/billing/billing-plan.types'
1818
import { StoragePlanService } from '@core/modules/platform/billing/storage-plan.service'
19+
import { TenantDomainService } from '@core/modules/platform/tenant/tenant-domain.service'
1920
import type { FlatSubscriptionEvent } from '@creem_io/better-auth'
2021
import { creem } from '@creem_io/better-auth'
2122
import type { OnModuleInit } from '@tsuki-hono/common'
@@ -61,6 +62,7 @@ export class AuthProvider implements OnModuleInit {
6162
private readonly appleClientSecrets: AppleClientSecretService,
6263
private readonly billingPlanService: BillingPlanService,
6364
private readonly storagePlanService: StoragePlanService,
65+
private readonly tenantDomainService: TenantDomainService,
6466
) {}
6567

6668
async onModuleInit(): Promise<void> {
@@ -536,11 +538,13 @@ export class AuthProvider implements OnModuleInit {
536538
}): Promise<void> {
537539
const { tenantId, planId, storagePlanId, event } = params
538540
let handled = false
541+
let shouldDeleteCustomDomains = false
539542

540543
if (planId) {
541544
handled = true
542545
try {
543546
await this.billingPlanService.updateTenantPlan(tenantId, 'free')
547+
shouldDeleteCustomDomains = true
544548
logger.info(`[AuthProvider] Tenant ${tenantId} downgraded to free via Creem (${event})`)
545549
}
546550
catch (error) {
@@ -559,6 +563,20 @@ export class AuthProvider implements OnModuleInit {
559563
}
560564
}
561565

566+
if (shouldDeleteCustomDomains) {
567+
try {
568+
const deletedCount = await this.tenantDomainService.deleteDomainsForTenant(tenantId)
569+
logger.info(`[AuthProvider] Deleted ${deletedCount} custom domains for tenant ${tenantId} after Creem ${event}`)
570+
}
571+
catch (error) {
572+
logger.error(
573+
`[AuthProvider] Failed to delete custom domains for tenant ${tenantId} after Creem ${event}`,
574+
error,
575+
)
576+
throw error
577+
}
578+
}
579+
562580
if (!handled) {
563581
logger.warn(`[AuthProvider] Creem ${event} event for tenant ${tenantId} missing plan metadata`)
564582
}

be/apps/core/src/modules/platform/tenant/tenant-domain.service.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ describe('tenantDomainService plan enforcement', () => {
1111
const repository = {
1212
countByTenant: vi.fn(),
1313
createDomain: vi.fn(),
14+
deleteDomain: vi.fn(),
1415
findActiveByDomain: vi.fn(),
1516
findByDomain: vi.fn(),
17+
listByTenant: vi.fn(),
1618
}
1719
const systemSettings = {
1820
getSettings: vi.fn(),
@@ -22,6 +24,7 @@ describe('tenantDomainService plan enforcement', () => {
2224
}
2325
const cloudflare = {
2426
createOrGet: vi.fn(),
27+
delete: vi.fn(),
2528
}
2629
const billingPlanService = {
2730
ensureCustomDomainAllowance: vi.fn(),
@@ -41,6 +44,7 @@ describe('tenantDomainService plan enforcement', () => {
4144
repository.findByDomain.mockResolvedValue(null)
4245
repository.findActiveByDomain.mockResolvedValue(null)
4346
repository.countByTenant.mockResolvedValue(0)
47+
repository.listByTenant.mockResolvedValue([])
4448
systemSettings.getSettings.mockResolvedValue({ baseDomain: 'afilmory.art' })
4549
billingPlanService.hasCustomDomainEntitlement.mockResolvedValue(true)
4650
})
@@ -81,4 +85,27 @@ describe('tenantDomainService plan enforcement', () => {
8185

8286
await expect(service.resolveTenantByDomain('photos.example.net')).resolves.toBeNull()
8387
})
88+
89+
it('deletes every Cloudflare hostname and local record when a tenant loses the plan entitlement', async () => {
90+
repository.listByTenant.mockResolvedValue([
91+
{ id: 'domain-1', cloudflareHostnameId: 'cf-hostname-1' },
92+
{ id: 'domain-2', cloudflareHostnameId: null },
93+
])
94+
95+
await expect(service.deleteDomainsForTenant('tenant-1')).resolves.toBe(2)
96+
97+
expect(cloudflare.delete).toHaveBeenCalledOnce()
98+
expect(cloudflare.delete).toHaveBeenCalledWith('cf-hostname-1')
99+
expect(repository.deleteDomain).toHaveBeenNthCalledWith(1, 'domain-1')
100+
expect(repository.deleteDomain).toHaveBeenNthCalledWith(2, 'domain-2')
101+
})
102+
103+
it('keeps the local record when Cloudflare deletion fails so a webhook retry can finish cleanup', async () => {
104+
repository.listByTenant.mockResolvedValue([{ id: 'domain-1', cloudflareHostnameId: 'cf-hostname-1' }])
105+
cloudflare.delete.mockRejectedValueOnce(new Error('Cloudflare unavailable'))
106+
107+
await expect(service.deleteDomainsForTenant('tenant-1')).rejects.toThrow('Cloudflare unavailable')
108+
109+
expect(repository.deleteDomain).not.toHaveBeenCalled()
110+
})
84111
})

be/apps/core/src/modules/platform/tenant/tenant-domain.service.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,16 @@ export class TenantDomainService {
127127
if (aggregate.tenant.id !== tenantContext.tenant.id) {
128128
throw new BizException(ErrorCode.COMMON_FORBIDDEN, { message: '无法操作其他空间的域名' })
129129
}
130-
if (aggregate.domain.cloudflareHostnameId) {
131-
await this.cloudflare.delete(aggregate.domain.cloudflareHostnameId)
130+
131+
await this.deleteDomainRecord(aggregate.domain)
132+
}
133+
134+
async deleteDomainsForTenant(tenantId: string): Promise<number> {
135+
const domains = await this.repository.listByTenant(tenantId)
136+
for (const domain of domains) {
137+
await this.deleteDomainRecord(domain)
132138
}
133-
await this.repository.deleteDomain(domainId)
139+
return domains.length
134140
}
135141

136142
private normalizeDomain(value?: string | null): string | null {
@@ -160,6 +166,13 @@ export class TenantDomainService {
160166
return await this.syncCloudflareState(aggregate.domain.id, cloudflareHostname)
161167
}
162168

169+
private async deleteDomainRecord(domain: TenantDomainRecord): Promise<void> {
170+
if (domain.cloudflareHostnameId) {
171+
await this.cloudflare.delete(domain.cloudflareHostnameId)
172+
}
173+
await this.repository.deleteDomain(domain.id)
174+
}
175+
163176
private async syncCloudflareState(
164177
domainId: string,
165178
cloudflareHostname: CloudflareCustomHostname,

0 commit comments

Comments
 (0)