@open-mercato/core 0.6.1-develop.3260.1.dc719cac84 → 0.6.1-develop.3278.1.a4d26475e4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/domain_mapping/index.js +31 -0
  3. package/dist/generated/entities/domain_mapping/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +2 -1
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +16 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/salesUi.js +18 -17
  9. package/dist/helpers/integration/salesUi.js.map +2 -2
  10. package/dist/modules/customer_accounts/acl.js +2 -1
  11. package/dist/modules/customer_accounts/acl.js.map +2 -2
  12. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.js +110 -0
  13. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.js.map +7 -0
  14. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.js +114 -0
  15. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.js.map +7 -0
  16. package/dist/modules/customer_accounts/api/admin/domain-mappings.js +271 -0
  17. package/dist/modules/customer_accounts/api/admin/domain-mappings.js.map +7 -0
  18. package/dist/modules/customer_accounts/api/domain-check.js +70 -0
  19. package/dist/modules/customer_accounts/api/domain-check.js.map +7 -0
  20. package/dist/modules/customer_accounts/api/domain-resolve/all.js +128 -0
  21. package/dist/modules/customer_accounts/api/domain-resolve/all.js.map +7 -0
  22. package/dist/modules/customer_accounts/api/domain-resolve.js +84 -0
  23. package/dist/modules/customer_accounts/api/domain-resolve.js.map +7 -0
  24. package/dist/modules/customer_accounts/api/login.js +14 -3
  25. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/magic-link/request.js +14 -3
  27. package/dist/modules/customer_accounts/api/magic-link/request.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/password/reset-request.js +14 -3
  29. package/dist/modules/customer_accounts/api/password/reset-request.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/signup.js +32 -5
  31. package/dist/modules/customer_accounts/api/signup.js.map +2 -2
  32. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Actions.js +48 -0
  33. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Actions.js.map +7 -0
  34. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +43 -0
  35. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +7 -0
  36. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DnsConfig.js +53 -0
  37. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DnsConfig.js.map +7 -0
  38. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DomainPanel.js +69 -0
  39. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DomainPanel.js.map +7 -0
  40. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/OrgSwitcher.js +31 -0
  41. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/OrgSwitcher.js.map +7 -0
  42. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/RegisterDialog.js +147 -0
  43. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/RegisterDialog.js.map +7 -0
  44. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/StatusBadgeSection.js +39 -0
  45. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/StatusBadgeSection.js.map +7 -0
  46. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Stepper.js +60 -0
  47. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Stepper.js.map +7 -0
  48. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/domainStatusMap.js +11 -0
  49. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/domainStatusMap.js.map +7 -0
  50. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/types.js +1 -0
  51. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/types.js.map +7 -0
  52. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/page.js +361 -0
  53. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/page.js.map +7 -0
  54. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/page.meta.js +28 -0
  55. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/page.meta.js.map +7 -0
  56. package/dist/modules/customer_accounts/data/enrichers.js +78 -12
  57. package/dist/modules/customer_accounts/data/enrichers.js.map +2 -2
  58. package/dist/modules/customer_accounts/data/entities.js +85 -2
  59. package/dist/modules/customer_accounts/data/entities.js.map +2 -2
  60. package/dist/modules/customer_accounts/data/guards.js +130 -0
  61. package/dist/modules/customer_accounts/data/guards.js.map +7 -0
  62. package/dist/modules/customer_accounts/data/validators.js +19 -0
  63. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  64. package/dist/modules/customer_accounts/di.js +23 -1
  65. package/dist/modules/customer_accounts/di.js.map +2 -2
  66. package/dist/modules/customer_accounts/events.js +9 -2
  67. package/dist/modules/customer_accounts/events.js.map +2 -2
  68. package/dist/modules/customer_accounts/lib/customerAuthServer.js +27 -7
  69. package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
  70. package/dist/modules/customer_accounts/lib/customerUrl.js +48 -0
  71. package/dist/modules/customer_accounts/lib/customerUrl.js.map +7 -0
  72. package/dist/modules/customer_accounts/lib/hostname.js +55 -0
  73. package/dist/modules/customer_accounts/lib/hostname.js.map +7 -0
  74. package/dist/modules/customer_accounts/lib/platformDomains.js +7 -0
  75. package/dist/modules/customer_accounts/lib/platformDomains.js.map +7 -0
  76. package/dist/modules/customer_accounts/lib/proxyRanges.js +71 -0
  77. package/dist/modules/customer_accounts/lib/proxyRanges.js.map +7 -0
  78. package/dist/modules/customer_accounts/lib/rateLimiter.js +7 -0
  79. package/dist/modules/customer_accounts/lib/rateLimiter.js.map +2 -2
  80. package/dist/modules/customer_accounts/lib/resolveTenantContext.js +63 -0
  81. package/dist/modules/customer_accounts/lib/resolveTenantContext.js.map +7 -0
  82. package/dist/modules/customer_accounts/lib/secretCompare.js +13 -0
  83. package/dist/modules/customer_accounts/lib/secretCompare.js.map +7 -0
  84. package/dist/modules/customer_accounts/migrations/Migration20260430120000_customer_accounts.js +37 -0
  85. package/dist/modules/customer_accounts/migrations/Migration20260430120000_customer_accounts.js.map +7 -0
  86. package/dist/modules/customer_accounts/notifications.client.js +76 -0
  87. package/dist/modules/customer_accounts/notifications.client.js.map +2 -2
  88. package/dist/modules/customer_accounts/notifications.js +76 -0
  89. package/dist/modules/customer_accounts/notifications.js.map +2 -2
  90. package/dist/modules/customer_accounts/services/domainMappingService.js +497 -0
  91. package/dist/modules/customer_accounts/services/domainMappingService.js.map +7 -0
  92. package/dist/modules/customer_accounts/setup.js +48 -1
  93. package/dist/modules/customer_accounts/setup.js.map +2 -2
  94. package/dist/modules/customer_accounts/subscribers/invalidateDomainCache.js +29 -0
  95. package/dist/modules/customer_accounts/subscribers/invalidateDomainCache.js.map +7 -0
  96. package/dist/modules/customer_accounts/subscribers/notifyDomainActivated.js +29 -0
  97. package/dist/modules/customer_accounts/subscribers/notifyDomainActivated.js.map +7 -0
  98. package/dist/modules/customer_accounts/subscribers/notifyDomainDnsFailed.js +30 -0
  99. package/dist/modules/customer_accounts/subscribers/notifyDomainDnsFailed.js.map +7 -0
  100. package/dist/modules/customer_accounts/subscribers/notifyDomainTlsFailed.js +31 -0
  101. package/dist/modules/customer_accounts/subscribers/notifyDomainTlsFailed.js.map +7 -0
  102. package/dist/modules/customer_accounts/subscribers/notifyDomainVerified.js +29 -0
  103. package/dist/modules/customer_accounts/subscribers/notifyDomainVerified.js.map +7 -0
  104. package/dist/modules/customer_accounts/workers/domainTlsRetryWorker.js +67 -0
  105. package/dist/modules/customer_accounts/workers/domainTlsRetryWorker.js.map +7 -0
  106. package/dist/modules/customer_accounts/workers/domainVerificationWorker.js +34 -0
  107. package/dist/modules/customer_accounts/workers/domainVerificationWorker.js.map +7 -0
  108. package/dist/modules/customers/api/companies/route.js +4 -9
  109. package/dist/modules/customers/api/companies/route.js.map +2 -2
  110. package/dist/modules/customers/api/deals/route.js +4 -9
  111. package/dist/modules/customers/api/deals/route.js.map +2 -2
  112. package/dist/modules/customers/api/people/route.js +4 -9
  113. package/dist/modules/customers/api/people/route.js.map +2 -2
  114. package/dist/modules/customers/api/utils.js +0 -12
  115. package/dist/modules/customers/api/utils.js.map +2 -2
  116. package/dist/modules/customers/backend/customers/companies/page.js +361 -284
  117. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  118. package/dist/modules/customers/backend/customers/deals/page.js +378 -473
  119. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  120. package/dist/modules/customers/backend/customers/people/page.js +368 -286
  121. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  122. package/dist/modules/customers/components/detail/assignableStaff.js +15 -1
  123. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  124. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +188 -36
  125. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +2 -2
  126. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js +2 -2
  127. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js.map +2 -2
  128. package/dist/modules/customers/widgets/injection-table.js +6 -0
  129. package/dist/modules/customers/widgets/injection-table.js.map +2 -2
  130. package/dist/modules/perspectives/services/perspectiveService.js +16 -2
  131. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  132. package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js +1 -0
  133. package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js.map +2 -2
  134. package/dist/modules/query_index/lib/engine.js +31 -8
  135. package/dist/modules/query_index/lib/engine.js.map +2 -2
  136. package/generated/entities/domain_mapping/index.ts +14 -0
  137. package/generated/entities.ids.generated.ts +2 -1
  138. package/generated/entity-fields-registry.ts +16 -0
  139. package/package.json +5 -5
  140. package/src/helpers/integration/salesUi.ts +27 -23
  141. package/src/modules/customer_accounts/acl.ts +1 -0
  142. package/src/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.ts +119 -0
  143. package/src/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.ts +123 -0
  144. package/src/modules/customer_accounts/api/admin/domain-mappings.ts +303 -0
  145. package/src/modules/customer_accounts/api/domain-check.ts +83 -0
  146. package/src/modules/customer_accounts/api/domain-resolve/all.ts +139 -0
  147. package/src/modules/customer_accounts/api/domain-resolve.ts +91 -0
  148. package/src/modules/customer_accounts/api/login.ts +14 -3
  149. package/src/modules/customer_accounts/api/magic-link/request.ts +14 -3
  150. package/src/modules/customer_accounts/api/password/reset-request.ts +15 -3
  151. package/src/modules/customer_accounts/api/signup.ts +36 -5
  152. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Actions.tsx +70 -0
  153. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +66 -0
  154. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DnsConfig.tsx +78 -0
  155. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DomainPanel.tsx +90 -0
  156. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/OrgSwitcher.tsx +43 -0
  157. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/RegisterDialog.tsx +178 -0
  158. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/StatusBadgeSection.tsx +56 -0
  159. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Stepper.tsx +69 -0
  160. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/domainStatusMap.ts +10 -0
  161. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/types.ts +31 -0
  162. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/page.meta.ts +26 -0
  163. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/page.tsx +411 -0
  164. package/src/modules/customer_accounts/data/enrichers.ts +95 -12
  165. package/src/modules/customer_accounts/data/entities.ts +80 -1
  166. package/src/modules/customer_accounts/data/guards.ts +155 -0
  167. package/src/modules/customer_accounts/data/validators.ts +26 -0
  168. package/src/modules/customer_accounts/di.ts +35 -1
  169. package/src/modules/customer_accounts/events.ts +8 -1
  170. package/src/modules/customer_accounts/i18n/de.json +78 -0
  171. package/src/modules/customer_accounts/i18n/en.json +78 -0
  172. package/src/modules/customer_accounts/i18n/es.json +78 -0
  173. package/src/modules/customer_accounts/i18n/pl.json +78 -0
  174. package/src/modules/customer_accounts/lib/customerAuthServer.ts +44 -8
  175. package/src/modules/customer_accounts/lib/customerUrl.ts +72 -0
  176. package/src/modules/customer_accounts/lib/hostname.ts +76 -0
  177. package/src/modules/customer_accounts/lib/platformDomains.ts +14 -0
  178. package/src/modules/customer_accounts/lib/proxyRanges.ts +85 -0
  179. package/src/modules/customer_accounts/lib/rateLimiter.ts +10 -0
  180. package/src/modules/customer_accounts/lib/resolveTenantContext.ts +96 -0
  181. package/src/modules/customer_accounts/lib/secretCompare.ts +20 -0
  182. package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +341 -0
  183. package/src/modules/customer_accounts/migrations/Migration20260430120000_customer_accounts.ts +38 -0
  184. package/src/modules/customer_accounts/notifications.client.ts +76 -0
  185. package/src/modules/customer_accounts/notifications.ts +76 -0
  186. package/src/modules/customer_accounts/services/domainMappingService.ts +632 -0
  187. package/src/modules/customer_accounts/setup.ts +81 -1
  188. package/src/modules/customer_accounts/subscribers/invalidateDomainCache.ts +35 -0
  189. package/src/modules/customer_accounts/subscribers/notifyDomainActivated.ts +32 -0
  190. package/src/modules/customer_accounts/subscribers/notifyDomainDnsFailed.ts +33 -0
  191. package/src/modules/customer_accounts/subscribers/notifyDomainTlsFailed.ts +34 -0
  192. package/src/modules/customer_accounts/subscribers/notifyDomainVerified.ts +32 -0
  193. package/src/modules/customer_accounts/workers/domainTlsRetryWorker.ts +84 -0
  194. package/src/modules/customer_accounts/workers/domainVerificationWorker.ts +45 -0
  195. package/src/modules/customers/api/companies/route.ts +4 -9
  196. package/src/modules/customers/api/deals/route.ts +4 -9
  197. package/src/modules/customers/api/people/route.ts +4 -9
  198. package/src/modules/customers/api/utils.ts +0 -14
  199. package/src/modules/customers/backend/customers/companies/page.tsx +321 -237
  200. package/src/modules/customers/backend/customers/deals/page.tsx +321 -455
  201. package/src/modules/customers/backend/customers/people/page.tsx +331 -240
  202. package/src/modules/customers/components/detail/assignableStaff.ts +21 -0
  203. package/src/modules/customers/i18n/de.json +31 -0
  204. package/src/modules/customers/i18n/en.json +31 -0
  205. package/src/modules/customers/i18n/es.json +31 -0
  206. package/src/modules/customers/i18n/pl.json +31 -0
  207. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +197 -40
  208. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.ts +13 -7
  209. package/src/modules/customers/widgets/injection-table.ts +6 -0
  210. package/src/modules/perspectives/services/perspectiveService.ts +30 -2
  211. package/src/modules/portal/frontend/[orgSlug]/portal/login/page.tsx +1 -0
  212. package/src/modules/query_index/lib/engine.ts +46 -8
@@ -0,0 +1,632 @@
1
+ import { Resolver, promises as dnsPromises } from 'node:dns'
2
+ import { request as httpsRequest } from 'node:https'
3
+ import { setTimeout as delay } from 'node:timers/promises'
4
+ import { EntityManager } from '@mikro-orm/postgresql'
5
+ import {
6
+ DomainMapping,
7
+ type DomainStatus,
8
+ } from '@open-mercato/core/modules/customer_accounts/data/entities'
9
+ import { Organization } from '@open-mercato/core/modules/directory/data/entities'
10
+ import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
11
+ import { normalizeHostname, tryNormalizeHostname } from '@open-mercato/core/modules/customer_accounts/lib/hostname'
12
+ import { platformDomains } from '@open-mercato/core/modules/customer_accounts/lib/platformDomains'
13
+ import { detectProxy, isInKnownProxyRange } from '@open-mercato/core/modules/customer_accounts/lib/proxyRanges'
14
+
15
+ const DOMAIN_ROUTING_TAG = 'domain_routing'
16
+ const RESOLVE_KEY_PREFIX = 'domain_routing:resolve'
17
+ const ACTIVE_BY_ORG_KEY_PREFIX = 'domain_routing:active-by-org'
18
+ const RESOLVE_TTL_MS = 5 * 60_000
19
+ const TLS_HEALTH_CHECK_TIMEOUT_MS = 10_000
20
+ const TLS_HEALTH_CHECK_RETRY_DELAYS_MS = [1_000, 4_000, 16_000]
21
+ const DEFAULT_DNS_RECHECK_THRESHOLD_MS = 5 * 60_000
22
+ const DEFAULT_TLS_MAX_RETRIES = 6
23
+
24
+ export type ResolveResult = {
25
+ domainMappingId: string
26
+ hostname: string
27
+ tenantId: string
28
+ organizationId: string
29
+ orgSlug: string | null
30
+ status: DomainStatus
31
+ }
32
+
33
+ export type DnsDiagnostics = {
34
+ expectedCnameTarget: string
35
+ expectedARecordTarget: string | null
36
+ detectedRecords: Array<{ type: 'CNAME' | 'A'; value: string; proxy?: string }>
37
+ reverseResolve?: { attempted: boolean; originHeaderPresent: boolean }
38
+ suggestion: string
39
+ }
40
+
41
+ export type VerifyResult = {
42
+ domainMapping: DomainMapping
43
+ diagnostics?: DnsDiagnostics
44
+ }
45
+
46
+ export type RegisterInput = {
47
+ hostname: string
48
+ organizationId: string
49
+ tenantId: string
50
+ replacesDomainId?: string
51
+ }
52
+
53
+ type CacheService = {
54
+ get(key: string, options?: unknown): Promise<unknown>
55
+ set(key: string, value: unknown, options?: { ttl?: number; tags?: string[] }): Promise<void>
56
+ deleteByTags(tags: string[]): Promise<number>
57
+ }
58
+
59
+ type DnsResolverContract = {
60
+ resolveCname(hostname: string): Promise<string[]>
61
+ resolve4(hostname: string): Promise<string[]>
62
+ }
63
+
64
+ type HealthCheckContract = (hostname: string, timeoutMs: number) => Promise<{
65
+ ok: boolean
66
+ originHeaderPresent: boolean
67
+ reason?: string
68
+ }>
69
+
70
+ const defaultDnsResolver: DnsResolverContract = {
71
+ async resolveCname(hostname) {
72
+ try {
73
+ return await dnsPromises.resolveCname(hostname)
74
+ } catch (err) {
75
+ const code = (err as NodeJS.ErrnoException).code
76
+ if (code === 'ENODATA' || code === 'ENOTFOUND') return []
77
+ throw err
78
+ }
79
+ },
80
+ async resolve4(hostname) {
81
+ try {
82
+ return await dnsPromises.resolve4(hostname)
83
+ } catch (err) {
84
+ const code = (err as NodeJS.ErrnoException).code
85
+ if (code === 'ENODATA' || code === 'ENOTFOUND') return []
86
+ throw err
87
+ }
88
+ },
89
+ }
90
+
91
+ const defaultHealthCheck: HealthCheckContract = (hostname, timeoutMs) =>
92
+ new Promise((resolve) => {
93
+ const headerName = (process.env.CUSTOMER_DOMAIN_ORIGIN_HEADER ?? 'X-Open-Mercato-Origin').toLowerCase()
94
+ const req = httpsRequest(
95
+ {
96
+ host: hostname,
97
+ port: 443,
98
+ path: '/api/customer_accounts/domain-check',
99
+ method: 'GET',
100
+ headers: { 'X-Domain-Check-Secret': process.env.DOMAIN_CHECK_SECRET ?? '' },
101
+ timeout: timeoutMs,
102
+ },
103
+ (res) => {
104
+ const headerValue = res.headers[headerName]
105
+ const originHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue
106
+ const originHeaderPresent = typeof originHeader === 'string' && originHeader === '1'
107
+ const status = res.statusCode ?? 0
108
+ res.resume() // discard body
109
+ resolve({
110
+ ok: status >= 200 && status < 400,
111
+ originHeaderPresent,
112
+ reason: status >= 200 && status < 400 ? undefined : `HTTP ${status}`,
113
+ })
114
+ },
115
+ )
116
+ req.on('timeout', () => {
117
+ req.destroy(new Error('TLS health check timed out'))
118
+ })
119
+ req.on('error', (err) => {
120
+ resolve({ ok: false, originHeaderPresent: false, reason: (err as Error).message })
121
+ })
122
+ req.end()
123
+ })
124
+
125
+ export class DomainMappingService {
126
+ private cache: CacheService | null
127
+ private dns: DnsResolverContract
128
+ private healthCheckImpl: HealthCheckContract
129
+
130
+ constructor(
131
+ private em: EntityManager,
132
+ deps?: {
133
+ cacheService?: CacheService
134
+ dnsResolver?: DnsResolverContract
135
+ healthCheck?: HealthCheckContract
136
+ },
137
+ ) {
138
+ this.cache = deps?.cacheService ?? null
139
+ this.dns = deps?.dnsResolver ?? defaultDnsResolver
140
+ this.healthCheckImpl = deps?.healthCheck ?? defaultHealthCheck
141
+ }
142
+
143
+ // -------------------------------------------------------------------------
144
+ // Read paths
145
+ // -------------------------------------------------------------------------
146
+
147
+ async findById(id: string, scope?: { tenantId?: string }): Promise<DomainMapping | null> {
148
+ const where: Record<string, unknown> = { id }
149
+ if (scope?.tenantId) where.tenantId = scope.tenantId
150
+ return this.em.findOne(DomainMapping, where as never)
151
+ }
152
+
153
+ async findByOrganization(
154
+ organizationId: string,
155
+ scope?: { tenantId?: string },
156
+ ): Promise<DomainMapping[]> {
157
+ const where: Record<string, unknown> = { organizationId }
158
+ if (scope?.tenantId) where.tenantId = scope.tenantId
159
+ return this.em.find(DomainMapping, where as never, { orderBy: { createdAt: 'asc' } })
160
+ }
161
+
162
+ async resolveByHostname(input: string): Promise<ResolveResult | null> {
163
+ const hostname = tryNormalizeHostname(input)
164
+ if (!hostname) return null
165
+ if (platformDomains().includes(hostname)) return null
166
+
167
+ const cacheKey = `${RESOLVE_KEY_PREFIX}:${hostname}`
168
+ if (this.cache) {
169
+ const cached = (await this.cache.get(cacheKey)) as ResolveResult | null | undefined
170
+ if (cached !== undefined && cached !== null) return cached
171
+ }
172
+
173
+ const result = await this.lookupResolveResult(hostname)
174
+ if (this.cache) {
175
+ await this.cache.set(cacheKey, result, {
176
+ ttl: RESOLVE_TTL_MS,
177
+ tags: [DOMAIN_ROUTING_TAG, `${DOMAIN_ROUTING_TAG}:${hostname}`],
178
+ })
179
+ }
180
+ return result
181
+ }
182
+
183
+ async isAllowedForTls(input: string): Promise<{ organizationId: string; status: DomainStatus } | null> {
184
+ const hostname = tryNormalizeHostname(input)
185
+ if (!hostname) return null
186
+ if (platformDomains().includes(hostname)) return null
187
+ const row = await this.em.findOne(DomainMapping, {
188
+ hostname,
189
+ status: { $in: ['active', 'verified'] },
190
+ } as never)
191
+ if (!row) return null
192
+ return { organizationId: row.organizationId, status: row.status }
193
+ }
194
+
195
+ async resolveActiveByOrg(organizationId: string): Promise<{ hostname: string; status: DomainStatus } | null> {
196
+ const cacheKey = `${ACTIVE_BY_ORG_KEY_PREFIX}:${organizationId}`
197
+ if (this.cache) {
198
+ const cached = (await this.cache.get(cacheKey)) as { hostname: string; status: DomainStatus } | null | undefined
199
+ if (cached !== undefined && cached !== null) return cached
200
+ }
201
+
202
+ const row = await this.em.findOne(DomainMapping, {
203
+ organizationId,
204
+ status: 'active',
205
+ } as never)
206
+ const result = row ? { hostname: row.hostname, status: row.status } : null
207
+
208
+ if (this.cache) {
209
+ await this.cache.set(cacheKey, result, {
210
+ ttl: RESOLVE_TTL_MS,
211
+ tags: [DOMAIN_ROUTING_TAG, `${DOMAIN_ROUTING_TAG}:org:${organizationId}`],
212
+ })
213
+ }
214
+ return result
215
+ }
216
+
217
+ async resolveAll(): Promise<ResolveResult[]> {
218
+ const rows = await this.em.find(DomainMapping, { status: 'active' } as never)
219
+ if (rows.length === 0) return []
220
+ const orgIds = Array.from(new Set(rows.map((r) => r.organizationId)))
221
+ const orgs = await this.em.find(Organization, { id: { $in: orgIds } } as never)
222
+ const slugByOrg = new Map<string, string | null>(orgs.map((o) => [o.id, o.slug ?? null]))
223
+
224
+ return rows.map<ResolveResult>((row) => ({
225
+ domainMappingId: row.id,
226
+ hostname: row.hostname,
227
+ tenantId: row.tenantId,
228
+ organizationId: row.organizationId,
229
+ orgSlug: slugByOrg.get(row.organizationId) ?? null,
230
+ status: row.status,
231
+ }))
232
+ }
233
+
234
+ // -------------------------------------------------------------------------
235
+ // Worker queries
236
+ // -------------------------------------------------------------------------
237
+
238
+ async findPendingVerification(threshold?: { olderThanMs?: number }): Promise<DomainMapping[]> {
239
+ const olderThan = threshold?.olderThanMs ?? DEFAULT_DNS_RECHECK_THRESHOLD_MS
240
+ const cutoff = new Date(Date.now() - olderThan)
241
+ return this.em.find(
242
+ DomainMapping,
243
+ {
244
+ status: { $in: ['pending', 'dns_failed'] },
245
+ $or: [{ lastDnsCheckAt: null }, { lastDnsCheckAt: { $lt: cutoff } }],
246
+ } as never,
247
+ { orderBy: { lastDnsCheckAt: 'asc' } },
248
+ )
249
+ }
250
+
251
+ async findPendingTls(options?: { maxRetries?: number; batchSize?: number }): Promise<DomainMapping[]> {
252
+ const maxRetries = options?.maxRetries ?? DEFAULT_TLS_MAX_RETRIES
253
+ const limit = options?.batchSize ?? 50
254
+ return this.em.find(
255
+ DomainMapping,
256
+ {
257
+ $or: [
258
+ { status: 'verified' },
259
+ { status: 'tls_failed', tlsRetryCount: { $lt: maxRetries } },
260
+ ],
261
+ } as never,
262
+ { orderBy: { updatedAt: 'asc' }, limit },
263
+ )
264
+ }
265
+
266
+ // -------------------------------------------------------------------------
267
+ // Write paths
268
+ // -------------------------------------------------------------------------
269
+
270
+ async register(input: RegisterInput): Promise<DomainMapping> {
271
+ const hostname = normalizeHostname(input.hostname)
272
+
273
+ let replacesDomain: DomainMapping | null = null
274
+ if (input.replacesDomainId) {
275
+ replacesDomain = await this.em.findOne(DomainMapping, {
276
+ id: input.replacesDomainId,
277
+ tenantId: input.tenantId,
278
+ } as never)
279
+ }
280
+
281
+ const entity = this.em.create(DomainMapping, {
282
+ hostname,
283
+ tenantId: input.tenantId,
284
+ organizationId: input.organizationId,
285
+ replacesDomain: replacesDomain ?? null,
286
+ provider: 'traefik',
287
+ status: 'pending',
288
+ tlsRetryCount: 0,
289
+ createdAt: new Date(),
290
+ } as never)
291
+ await this.em.persist(entity).flush()
292
+
293
+ await emitCustomerAccountsEvent('customer_accounts.domain_mapping.created', {
294
+ id: entity.id,
295
+ hostname: entity.hostname,
296
+ organizationId: entity.organizationId,
297
+ tenantId: entity.tenantId,
298
+ status: entity.status,
299
+ } as never)
300
+ await this.invalidateCacheFor(entity.hostname, entity.organizationId)
301
+
302
+ return entity
303
+ }
304
+
305
+ async verify(id: string): Promise<VerifyResult> {
306
+ const entity = await this.em.findOne(DomainMapping, { id } as never)
307
+ if (!entity) throw new Error(`DomainMapping ${id} not found`)
308
+
309
+ const expectedCname = process.env.CUSTOM_DOMAIN_CNAME_TARGET ?? ''
310
+ const expectedARecord = process.env.CUSTOM_DOMAIN_A_RECORD_TARGET ?? null
311
+ const now = new Date()
312
+ entity.lastDnsCheckAt = now
313
+
314
+ const verification = await this.runDnsVerification(entity.hostname, {
315
+ expectedCname,
316
+ expectedARecord,
317
+ })
318
+
319
+ if (verification.ok) {
320
+ entity.status = 'verified'
321
+ entity.verifiedAt = now
322
+ entity.dnsFailureReason = null
323
+ entity.tlsRetryCount = 0
324
+ entity.tlsFailureReason = null
325
+ await this.em.persist(entity).flush()
326
+ await emitCustomerAccountsEvent('customer_accounts.domain_mapping.verified', {
327
+ id: entity.id,
328
+ hostname: entity.hostname,
329
+ organizationId: entity.organizationId,
330
+ tenantId: entity.tenantId,
331
+ } as never)
332
+ await this.invalidateCacheFor(entity.hostname, entity.organizationId)
333
+ return { domainMapping: entity }
334
+ }
335
+
336
+ entity.status = 'dns_failed'
337
+ entity.dnsFailureReason = verification.reason
338
+ await this.em.persist(entity).flush()
339
+ await emitCustomerAccountsEvent('customer_accounts.domain_mapping.dns_failed', {
340
+ id: entity.id,
341
+ hostname: entity.hostname,
342
+ organizationId: entity.organizationId,
343
+ tenantId: entity.tenantId,
344
+ reason: verification.reason,
345
+ detectedRecords: verification.diagnostics.detectedRecords,
346
+ } as never)
347
+ await this.invalidateCacheFor(entity.hostname, entity.organizationId)
348
+ return { domainMapping: entity, diagnostics: verification.diagnostics }
349
+ }
350
+
351
+ async activate(id: string): Promise<DomainMapping> {
352
+ const entity = await this.em.findOne(DomainMapping, { id } as never)
353
+ if (!entity) throw new Error(`DomainMapping ${id} not found`)
354
+ if (entity.status === 'active') return entity
355
+ if (entity.status !== 'verified' && entity.status !== 'tls_failed') {
356
+ throw new Error(`DomainMapping ${id} cannot transition to active from ${entity.status}`)
357
+ }
358
+
359
+ entity.status = 'active'
360
+ entity.tlsRetryCount = 0
361
+ entity.tlsFailureReason = null
362
+ await this.em.persist(entity).flush()
363
+
364
+ await emitCustomerAccountsEvent('customer_accounts.domain_mapping.activated', {
365
+ id: entity.id,
366
+ hostname: entity.hostname,
367
+ organizationId: entity.organizationId,
368
+ tenantId: entity.tenantId,
369
+ } as never)
370
+
371
+ if (entity.replacesDomain) {
372
+ const replaced = await this.em.findOne(DomainMapping, {
373
+ id: (entity.replacesDomain as unknown as { id: string }).id,
374
+ } as never)
375
+ if (replaced) {
376
+ const replacedHostname = replaced.hostname
377
+ const replacedOrg = replaced.organizationId
378
+ this.em.remove(replaced)
379
+ await this.em.flush()
380
+ await emitCustomerAccountsEvent('customer_accounts.domain_mapping.replaced', {
381
+ id: entity.id,
382
+ hostname: entity.hostname,
383
+ organizationId: entity.organizationId,
384
+ tenantId: entity.tenantId,
385
+ replacedDomainId: replaced.id,
386
+ replacedHostname,
387
+ } as never)
388
+ await emitCustomerAccountsEvent('customer_accounts.domain_mapping.deleted', {
389
+ id: replaced.id,
390
+ hostname: replacedHostname,
391
+ organizationId: replacedOrg,
392
+ tenantId: replaced.tenantId,
393
+ } as never)
394
+ await this.invalidateCacheFor(replacedHostname, replacedOrg)
395
+ }
396
+ }
397
+
398
+ await this.invalidateCacheFor(entity.hostname, entity.organizationId)
399
+ return entity
400
+ }
401
+
402
+ async remove(id: string, scope?: { tenantId?: string }): Promise<void> {
403
+ const where: Record<string, unknown> = { id }
404
+ if (scope?.tenantId) where.tenantId = scope.tenantId
405
+ const entity = await this.em.findOne(DomainMapping, where as never)
406
+ if (!entity) return
407
+
408
+ const hostname = entity.hostname
409
+ const organizationId = entity.organizationId
410
+ const tenantId = entity.tenantId
411
+
412
+ this.em.remove(entity)
413
+ await this.em.flush()
414
+
415
+ await emitCustomerAccountsEvent('customer_accounts.domain_mapping.deleted', {
416
+ id,
417
+ hostname,
418
+ organizationId,
419
+ tenantId,
420
+ } as never)
421
+ await this.invalidateCacheFor(hostname, organizationId)
422
+ }
423
+
424
+ async healthCheck(id: string): Promise<DomainMapping> {
425
+ const entity = await this.em.findOne(DomainMapping, { id } as never)
426
+ if (!entity) throw new Error(`DomainMapping ${id} not found`)
427
+ if (entity.status === 'active') return entity
428
+ if (entity.status !== 'verified' && entity.status !== 'tls_failed') {
429
+ throw new Error(`DomainMapping ${id} cannot run health check from status ${entity.status}`)
430
+ }
431
+
432
+ let lastReason: string | null = null
433
+ for (let attempt = 0; attempt < TLS_HEALTH_CHECK_RETRY_DELAYS_MS.length; attempt++) {
434
+ const result = await this.healthCheckImpl(entity.hostname, TLS_HEALTH_CHECK_TIMEOUT_MS)
435
+ if (result.ok) {
436
+ return this.activate(entity.id)
437
+ }
438
+ lastReason = result.reason ?? 'TLS health check failed'
439
+ if (attempt + 1 < TLS_HEALTH_CHECK_RETRY_DELAYS_MS.length) {
440
+ await delay(TLS_HEALTH_CHECK_RETRY_DELAYS_MS[attempt])
441
+ }
442
+ }
443
+
444
+ entity.status = 'tls_failed'
445
+ entity.tlsFailureReason = lastReason ?? 'TLS health check failed'
446
+ entity.tlsRetryCount = (entity.tlsRetryCount ?? 0) + 1
447
+ await this.em.persist(entity).flush()
448
+
449
+ await emitCustomerAccountsEvent('customer_accounts.domain_mapping.tls_failed', {
450
+ id: entity.id,
451
+ hostname: entity.hostname,
452
+ organizationId: entity.organizationId,
453
+ tenantId: entity.tenantId,
454
+ reason: entity.tlsFailureReason,
455
+ retryCount: entity.tlsRetryCount,
456
+ } as never)
457
+ await this.invalidateCacheFor(entity.hostname, entity.organizationId)
458
+ return entity
459
+ }
460
+
461
+ // -------------------------------------------------------------------------
462
+ // Internals
463
+ // -------------------------------------------------------------------------
464
+
465
+ private async lookupResolveResult(hostname: string): Promise<ResolveResult | null> {
466
+ const row = await this.em.findOne(DomainMapping, { hostname, status: 'active' } as never)
467
+ if (!row) return null
468
+ const org = await this.em.findOne(Organization, { id: row.organizationId } as never)
469
+ return {
470
+ domainMappingId: row.id,
471
+ hostname: row.hostname,
472
+ tenantId: row.tenantId,
473
+ organizationId: row.organizationId,
474
+ orgSlug: org?.slug ?? null,
475
+ status: row.status,
476
+ }
477
+ }
478
+
479
+ private async invalidateCacheFor(hostname: string, organizationId: string | null): Promise<void> {
480
+ if (!this.cache) return
481
+ const tags = [DOMAIN_ROUTING_TAG, `${DOMAIN_ROUTING_TAG}:${hostname}`]
482
+ if (organizationId) tags.push(`${DOMAIN_ROUTING_TAG}:org:${organizationId}`)
483
+ try {
484
+ await this.cache.deleteByTags(tags)
485
+ } catch {
486
+ // Cache invalidation is best-effort — TTL backstop ensures eventual consistency.
487
+ }
488
+ }
489
+
490
+ private async runDnsVerification(
491
+ hostname: string,
492
+ expected: { expectedCname: string; expectedARecord: string | null },
493
+ ): Promise<
494
+ | {
495
+ ok: true
496
+ method: 'cname' | 'a-record' | 'reverse-resolve'
497
+ diagnostics: DnsDiagnostics
498
+ }
499
+ | { ok: false; reason: string; diagnostics: DnsDiagnostics }
500
+ > {
501
+ const detectedRecords: DnsDiagnostics['detectedRecords'] = []
502
+ const baseDiag = (overrides?: Partial<DnsDiagnostics>): DnsDiagnostics => ({
503
+ expectedCnameTarget: expected.expectedCname,
504
+ expectedARecordTarget: expected.expectedARecord,
505
+ detectedRecords,
506
+ suggestion: overrides?.suggestion ?? '',
507
+ reverseResolve: overrides?.reverseResolve,
508
+ })
509
+
510
+ // Phase 1: CNAME
511
+ let cnameRecords: string[] = []
512
+ try {
513
+ cnameRecords = await this.dns.resolveCname(hostname)
514
+ } catch (err) {
515
+ return {
516
+ ok: false,
517
+ reason: `DNS lookup error (CNAME): ${(err as Error).message}`,
518
+ diagnostics: baseDiag({
519
+ suggestion: 'DNS lookup failed unexpectedly. Try again in a few minutes.',
520
+ }),
521
+ }
522
+ }
523
+ for (const cname of cnameRecords) detectedRecords.push({ type: 'CNAME', value: cname })
524
+
525
+ if (expected.expectedCname && cnameRecords.length > 0) {
526
+ const expectedCname = tryNormalizeHostname(expected.expectedCname) ?? expected.expectedCname.toLowerCase()
527
+ const match = cnameRecords.some((c) => (tryNormalizeHostname(c) ?? c.toLowerCase()) === expectedCname)
528
+ if (match) {
529
+ return {
530
+ ok: true,
531
+ method: 'cname',
532
+ diagnostics: baseDiag({
533
+ suggestion: 'CNAME record matches the expected target.',
534
+ }),
535
+ }
536
+ }
537
+ return {
538
+ ok: false,
539
+ reason: `CNAME points to ${cnameRecords.join(', ')} instead of ${expected.expectedCname}`,
540
+ diagnostics: baseDiag({
541
+ suggestion: `Update your CNAME record to point to ${expected.expectedCname}.`,
542
+ }),
543
+ }
544
+ }
545
+
546
+ // Phase 2: A record
547
+ let aRecords: string[] = []
548
+ try {
549
+ aRecords = await this.dns.resolve4(hostname)
550
+ } catch (err) {
551
+ return {
552
+ ok: false,
553
+ reason: `DNS lookup error (A): ${(err as Error).message}`,
554
+ diagnostics: baseDiag({ suggestion: 'DNS lookup failed unexpectedly. Try again in a few minutes.' }),
555
+ }
556
+ }
557
+ for (const a of aRecords) {
558
+ const proxy = detectProxy(a)
559
+ detectedRecords.push({ type: 'A', value: a, ...(proxy ? { proxy } : {}) })
560
+ }
561
+
562
+ if (aRecords.length === 0) {
563
+ return {
564
+ ok: false,
565
+ reason: `No CNAME or A record found for ${hostname}`,
566
+ diagnostics: baseDiag({
567
+ suggestion: expected.expectedARecord
568
+ ? `For a subdomain, add a CNAME record pointing to ${expected.expectedCname}. For an apex domain, add an A record pointing to ${expected.expectedARecord}. DNS propagation can take up to 48 hours.`
569
+ : `Add a CNAME record pointing to ${expected.expectedCname}. DNS propagation can take up to 48 hours.`,
570
+ }),
571
+ }
572
+ }
573
+
574
+ if (expected.expectedARecord && aRecords.includes(expected.expectedARecord)) {
575
+ return {
576
+ ok: true,
577
+ method: 'a-record',
578
+ diagnostics: baseDiag({ suggestion: 'A record matches the expected target.' }),
579
+ }
580
+ }
581
+
582
+ // Phase 3: reverse-resolve through proxy
583
+ const proxiedRecords = aRecords.filter((ip) => isInKnownProxyRange(ip))
584
+ if (proxiedRecords.length > 0) {
585
+ const probe = await this.healthCheckImpl(hostname, TLS_HEALTH_CHECK_TIMEOUT_MS)
586
+ if (probe.ok && probe.originHeaderPresent) {
587
+ return {
588
+ ok: true,
589
+ method: 'reverse-resolve',
590
+ diagnostics: baseDiag({
591
+ reverseResolve: { attempted: true, originHeaderPresent: true },
592
+ suggestion: 'Domain is proxied — reverse-resolve confirmed traffic reaches our origin.',
593
+ }),
594
+ }
595
+ }
596
+ return {
597
+ ok: false,
598
+ reason: 'A record points to a known proxy IP, but reverse-resolve over HTTPS did not reach our server',
599
+ diagnostics: baseDiag({
600
+ reverseResolve: { attempted: true, originHeaderPresent: false },
601
+ suggestion: expected.expectedCname
602
+ ? `Your DNS uses a proxy. Either disable the proxy and add a CNAME → ${expected.expectedCname}, or configure your proxy to forward traffic to ${expected.expectedCname}.`
603
+ : 'Disable the DNS proxy or configure it to forward traffic to our platform.',
604
+ }),
605
+ }
606
+ }
607
+
608
+ return {
609
+ ok: false,
610
+ reason: expected.expectedARecord
611
+ ? `A record points to ${aRecords.join(', ')} instead of ${expected.expectedARecord}`
612
+ : `A record points to ${aRecords.join(', ')} but apex-domain registration is not enabled on this deployment`,
613
+ diagnostics: baseDiag({
614
+ suggestion: expected.expectedARecord
615
+ ? `Update your A record to point to ${expected.expectedARecord}.`
616
+ : `Apex-domain registration is not enabled on this deployment. Use a subdomain (e.g., shop.${hostname}) and add a CNAME pointing to ${expected.expectedCname}.`,
617
+ }),
618
+ }
619
+ }
620
+ }
621
+
622
+ // Re-exported for tests; allow injection of fakes.
623
+ export const __testing__ = {
624
+ DEFAULT_DNS_RECHECK_THRESHOLD_MS,
625
+ DEFAULT_TLS_MAX_RETRIES,
626
+ TLS_HEALTH_CHECK_RETRY_DELAYS_MS,
627
+ RESOLVE_TTL_MS,
628
+ DOMAIN_ROUTING_TAG,
629
+ defaultHealthCheck,
630
+ defaultDnsResolver,
631
+ Resolver,
632
+ }