krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.5.0: DomainDig/Models.swift · raw
1import Foundation
2
3enum ServiceResult<Value> {
4 case success(Value)
5 case empty(String)
6 case error(String)
7}
8
9enum ConfidenceLevel: String, Codable {
10 case high
11 case medium
12 case low
13
14 var title: String {
15 rawValue.capitalized
16 }
17}
18
19enum InspectionErrorKind: String, Codable {
20 case network
21 case timeout
22 case rateLimited
23 case parsing
24 case unsupported
25 case unavailable
26 case unknown
27
28 var title: String {
29 switch self {
30 case .network:
31 return "Network"
32 case .timeout:
33 return "Timeout"
34 case .rateLimited:
35 return "Rate limited"
36 case .parsing:
37 return "Parsing"
38 case .unsupported:
39 return "Unsupported"
40 case .unavailable:
41 return "Unavailable"
42 case .unknown:
43 return "Unknown"
44 }
45 }
46}
47
48struct InspectionFailure: Codable, Equatable {
49 let kind: InspectionErrorKind
50 let message: String
51 let details: String?
52}
53
54struct SectionProvenance: Codable, Equatable {
55 let source: String
56 let collectedAt: Date
57 let provider: String?
58 let resolver: String?
59 let resultSource: LookupResultSource
60}
61
62enum LookupResultSource: String, Codable {
63 case live
64 case cached
65 case mixed
66 case snapshot
67
68 var label: String {
69 switch self {
70 case .live:
71 return "Live"
72 case .cached:
73 return "Cached"
74 case .mixed:
75 return "Mixed"
76 case .snapshot:
77 return "Snapshot"
78 }
79 }
80}
81
82enum LookupSectionKind: String, Codable, CaseIterable {
83 case dns
84 case availability
85 case ssl
86 case hsts
87 case httpHeaders
88 case reachability
89 case ipGeolocation
90 case emailSecurity
91 case ownership
92 case ptr
93 case redirectChain
94 case subdomains
95 case portScan
96 case suggestions
97}
98
99enum DomainAvailabilityStatus: String, Codable {
100 case available
101 case registered
102 case unknown
103}
104
105struct DomainAvailabilityResult: Codable {
106 let domain: String
107 let status: DomainAvailabilityStatus
108}
109
110struct DomainSuggestionResult: Identifiable, Codable {
111 let id: UUID
112 let domain: String
113 let status: DomainAvailabilityStatus
114
115 init(id: UUID = UUID(), domain: String, status: DomainAvailabilityStatus) {
116 self.id = id
117 self.domain = domain
118 self.status = status
119 }
120}
121
122struct WatchedDomain: Codable, Identifiable {
123 let id: UUID
124 let domain: String
125 let createdAt: Date
126 var lastKnownAvailability: DomainAvailabilityStatus?
127
128 init(
129 id: UUID = UUID(),
130 domain: String,
131 createdAt: Date = Date(),
132 lastKnownAvailability: DomainAvailabilityStatus? = nil
133 ) {
134 self.id = id
135 self.domain = domain
136 self.createdAt = createdAt
137 self.lastKnownAvailability = lastKnownAvailability
138 }
139}
140
141enum ChangeSeverity: Int, Codable, CaseIterable, Comparable {
142 case low
143 case medium
144 case high
145
146 static func < (lhs: ChangeSeverity, rhs: ChangeSeverity) -> Bool {
147 lhs.rawValue < rhs.rawValue
148 }
149
150 var title: String {
151 switch self {
152 case .low:
153 return "Low"
154 case .medium:
155 return "Medium"
156 case .high:
157 return "High"
158 }
159 }
160}
161
162enum CertificateWarningLevel: String, Codable {
163 case none
164 case warning
165 case critical
166
167 var title: String {
168 switch self {
169 case .none:
170 return "Healthy"
171 case .warning:
172 return "Warning"
173 case .critical:
174 return "Critical"
175 }
176 }
177}
178
179enum DomainHealth: String, Codable, Sendable, CaseIterable {
180 case healthy
181 case warning
182 case critical
183
184 var title: String {
185 rawValue.capitalized
186 }
187}
188
189struct PortfolioSnapshot: Codable, Sendable {
190 var totalDomains: Int
191 var healthyCount: Int
192 var warningCount: Int
193 var criticalCount: Int
194 var changedLast24h: Int
195 var expiringSoonCount: Int
196 var unreachableCount: Int
197}
198
199struct DomainChangeSummary: Codable, Equatable {
200 let hasChanges: Bool
201 let changedSections: [String]
202 let message: String
203 let severity: ChangeSeverity
204 let impactClassification: ChangeImpactClassification
205 let generatedAt: Date
206 let observedFacts: [String]
207 let inferredConclusions: [String]
208 let contextNote: String?
209 let riskAssessment: DomainRiskAssessment?
210 let insights: [String]
211 let riskScoreDelta: Int?
212
213 init(
214 hasChanges: Bool,
215 changedSections: [String],
216 message: String,
217 severity: ChangeSeverity,
218 impactClassification: ChangeImpactClassification,
219 generatedAt: Date,
220 observedFacts: [String] = [],
221 inferredConclusions: [String] = [],
222 contextNote: String? = nil,
223 riskAssessment: DomainRiskAssessment? = nil,
224 insights: [String] = [],
225 riskScoreDelta: Int? = nil
226 ) {
227 self.hasChanges = hasChanges
228 self.changedSections = changedSections
229 self.message = message
230 self.severity = severity
231 self.impactClassification = impactClassification
232 self.generatedAt = generatedAt
233 self.observedFacts = observedFacts
234 self.inferredConclusions = inferredConclusions
235 self.contextNote = contextNote
236 self.riskAssessment = riskAssessment
237 self.insights = insights
238 self.riskScoreDelta = riskScoreDelta
239 }
240
241 init(from decoder: Decoder) throws {
242 let container = try decoder.container(keyedBy: CodingKeys.self)
243 hasChanges = try container.decodeIfPresent(Bool.self, forKey: .hasChanges) ?? false
244 changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? []
245 generatedAt = try container.decode(Date.self, forKey: .generatedAt)
246 severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low)
247 impactClassification = try container.decodeIfPresent(ChangeImpactClassification.self, forKey: .impactClassification)
248 ?? (severity == .high ? .critical : (hasChanges ? .warning : .informational))
249 message = try container.decodeIfPresent(String.self, forKey: .message)
250 ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • "))
251 observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? []
252 inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? []
253 contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote)
254 riskAssessment = try container.decodeIfPresent(DomainRiskAssessment.self, forKey: .riskAssessment)
255 insights = try container.decodeIfPresent([String].self, forKey: .insights) ?? []
256 riskScoreDelta = try container.decodeIfPresent(Int.self, forKey: .riskScoreDelta)
257 }
258}
259
260enum BatchLookupSource: String, Codable {
261 case manual
262 case watchlistRefresh
263 case workflow
264}
265
266enum BatchLookupStatus: String, Codable {
267 case pending
268 case running
269 case completed
270 case failed
271}
272
273struct BatchLookupResult: Identifiable, Codable, Equatable {
274 let id: UUID
275 let domain: String
276 let historyEntryID: UUID?
277 let resultSource: LookupResultSource
278 let availability: DomainAvailabilityStatus?
279 let primaryIP: String?
280 let quickStatus: String
281 let summaryMessage: String?
282 let changeSeverity: ChangeSeverity?
283 let changeClassification: ChangeImpactClassification?
284 let certificateWarningLevel: CertificateWarningLevel
285 let riskScore: Int?
286 let riskLevel: RiskLevel?
287 let timestamp: Date
288 let status: BatchLookupStatus
289 let errorMessage: String?
290
291 init(
292 id: UUID = UUID(),
293 domain: String,
294 historyEntryID: UUID?,
295 resultSource: LookupResultSource = .live,
296 availability: DomainAvailabilityStatus?,
297 primaryIP: String?,
298 quickStatus: String,
299 summaryMessage: String? = nil,
300 changeSeverity: ChangeSeverity? = nil,
301 changeClassification: ChangeImpactClassification? = nil,
302 certificateWarningLevel: CertificateWarningLevel = .none,
303 riskScore: Int? = nil,
304 riskLevel: RiskLevel? = nil,
305 timestamp: Date,
306 status: BatchLookupStatus,
307 errorMessage: String? = nil
308 ) {
309 self.id = id
310 self.domain = domain
311 self.historyEntryID = historyEntryID
312 self.resultSource = resultSource
313 self.availability = availability
314 self.primaryIP = primaryIP
315 self.quickStatus = quickStatus
316 self.summaryMessage = summaryMessage
317 self.changeSeverity = changeSeverity
318 self.changeClassification = changeClassification
319 self.certificateWarningLevel = certificateWarningLevel
320 self.riskScore = riskScore
321 self.riskLevel = riskLevel
322 self.timestamp = timestamp
323 self.status = status
324 self.errorMessage = errorMessage
325 }
326
327 var hasMeaningfulChange: Bool {
328 quickStatus == "Changed"
329 || quickStatus == "High"
330 || quickStatus == "Critical"
331 || certificateWarningLevel != .none
332 || riskLevel == .high
333 || status == .failed
334 }
335}
336
337struct BatchSweepSummary: Identifiable, Equatable {
338 let id = UUID()
339 let source: BatchLookupSource
340 let totalDomains: Int
341 let changedDomains: Int
342 let unchangedDomains: Int
343 let warningDomains: Int
344 let results: [BatchLookupResult]
345 let generatedAt: Date
346}
347
348struct DomainWorkflow: Codable, Identifiable, Equatable {
349 let id: UUID
350 var name: String
351 var domains: [String]
352 var createdAt: Date
353 var updatedAt: Date
354 var notes: String?
355 var collaboration: CollaborationMetadata?
356
357 init(
358 id: UUID = UUID(),
359 name: String,
360 domains: [String],
361 createdAt: Date = Date(),
362 updatedAt: Date = Date(),
363 notes: String? = nil,
364 collaboration: CollaborationMetadata? = nil
365 ) {
366 self.id = id
367 self.name = name
368 self.domains = domains
369 self.createdAt = createdAt
370 self.updatedAt = updatedAt
371 self.notes = notes
372 self.collaboration = collaboration
373 }
374}
375
376struct WorkflowRunSummary: Identifiable, Equatable {
377 let id = UUID()
378 let workflowID: UUID
379 let workflowName: String
380 let totalDomains: Int
381 let changedDomains: Int
382 let unchangedDomains: Int
383 let warningDomains: Int
384 let results: [BatchLookupResult]
385 let workflowInsights: [WorkflowInsight]
386 let generatedAt: Date
387}
388
389enum DataCapability: String, Codable {
390 case ownershipHistory
391 case dnsHistory
392 case extendedSubdomains
393 case domainPricing
394}
395
396enum UsageCreditFeature: String, Codable, CaseIterable, Identifiable, Sendable {
397 case ownershipHistory
398 case dnsHistory
399 case extendedSubdomains
400
401 nonisolated var id: String { rawValue }
402
403 nonisolated var title: String {
404 switch self {
405 case .ownershipHistory:
406 return "Ownership history"
407 case .dnsHistory:
408 return "DNS history"
409 case .extendedSubdomains:
410 return "Extended subdomains"
411 }
412 }
413
414 nonisolated var defaultAllowance: Int {
415 switch self {
416 case .ownershipHistory, .dnsHistory:
417 return 8
418 case .extendedSubdomains:
419 return 12
420 }
421 }
422}
423
424struct UsageCreditStatus: Codable, Equatable, Sendable {
425 let feature: UsageCreditFeature
426 let remaining: Int
427 let total: Int
428 let resetContext: String
429
430 nonisolated var summary: String {
431 "\(remaining) uses remaining"
432 }
433
434 nonisolated var isExhausted: Bool {
435 remaining <= 0
436 }
437}
438
439struct DomainOwnershipHistoryEvent: Identifiable, Codable, Equatable, Sendable {
440 let id: UUID
441 let date: Date
442 let summary: String
443 let registrar: String?
444 let registrant: String?
445 let nameservers: [String]
446 let source: String
447 let isExternal: Bool
448
449 nonisolated init(
450 id: UUID = UUID(),
451 date: Date,
452 summary: String,
453 registrar: String? = nil,
454 registrant: String? = nil,
455 nameservers: [String] = [],
456 source: String,
457 isExternal: Bool
458 ) {
459 self.id = id
460 self.date = date
461 self.summary = summary
462 self.registrar = registrar
463 self.registrant = registrant
464 self.nameservers = nameservers
465 self.source = source
466 self.isExternal = isExternal
467 }
468}
469
470struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
471 let id: UUID
472 let date: Date
473 let summary: String
474 let aRecords: [String]
475 let nameservers: [String]
476 let recordSnapshots: [DNSHistoryRecordSnapshot]
477 let changedRecordTypes: [DNSRecordType]
478 let source: String
479 let isExternal: Bool
480
481 nonisolated init(
482 id: UUID = UUID(),
483 date: Date,
484 summary: String,
485 aRecords: [String] = [],
486 nameservers: [String] = [],
487 recordSnapshots: [DNSHistoryRecordSnapshot] = [],
488 changedRecordTypes: [DNSRecordType] = [],
489 source: String,
490 isExternal: Bool
491 ) {
492 self.id = id
493 self.date = date
494 self.summary = summary
495 self.aRecords = aRecords
496 self.nameservers = nameservers
497 self.recordSnapshots = recordSnapshots
498 self.changedRecordTypes = changedRecordTypes
499 self.source = source
500 self.isExternal = isExternal
501 }
502
503 init(from decoder: Decoder) throws {
504 let container = try decoder.container(keyedBy: CodingKeys.self)
505 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
506 date = try container.decode(Date.self, forKey: .date)
507 summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "DNS change observed"
508 aRecords = try container.decodeIfPresent([String].self, forKey: .aRecords) ?? []
509 nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
510 let decodedRecordSnapshots = try container.decodeIfPresent([DNSHistoryRecordSnapshot].self, forKey: .recordSnapshots) ?? []
511 if decodedRecordSnapshots.isEmpty {
512 var synthesizedSnapshots: [DNSHistoryRecordSnapshot] = []
513 if !aRecords.isEmpty {
514 synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords))
515 }
516 if !nameservers.isEmpty {
517 synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers))
518 }
519 recordSnapshots = synthesizedSnapshots
520 } else {
521 recordSnapshots = decodedRecordSnapshots
522 }
523 changedRecordTypes = try container.decodeIfPresent([DNSRecordType].self, forKey: .changedRecordTypes)
524 ?? recordSnapshots.map(\.recordType)
525 source = try container.decodeIfPresent(String.self, forKey: .source) ?? "Unknown"
526 isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false
527 }
528}
529
530struct DNSHistoryRecordSnapshot: Identifiable, Codable, Sendable, Equatable {
531 let id: UUID
532 let recordType: DNSRecordType
533 let values: [String]
534
535 nonisolated init(id: UUID = UUID(), recordType: DNSRecordType, values: [String]) {
536 self.id = id
537 self.recordType = recordType
538 self.values = values
539 }
540
541 static func == (lhs: DNSHistoryRecordSnapshot, rhs: DNSHistoryRecordSnapshot) -> Bool {
542 lhs.recordType == rhs.recordType && lhs.values == rhs.values
543 }
544}
545
546struct InferredProviderFingerprint: Codable, Equatable, Sendable {
547 let name: String
548 let confidence: ConfidenceLevel
549 let evidence: [String]
550}
551
552enum DomainClassificationKind: String, Codable, CaseIterable, Sendable {
553 case marketing
554 case app
555 case api
556 case auth
557 case docs
558 case staticSite = "static"
559 case infrastructure
560 case status
561 case unknown
562
563 var title: String {
564 switch self {
565 case .staticSite:
566 return "Static"
567 default:
568 return rawValue.capitalized
569 }
570 }
571}
572
573struct DomainClassificationSummary: Codable, Equatable, Sendable {
574 let kind: DomainClassificationKind
575 let confidence: ConfidenceLevel
576 let reasons: [String]
577}
578
579struct OwnershipTransitionEvent: Identifiable, Codable, Equatable, Sendable {
580 let id: UUID
581 let date: Date
582 let summary: String
583 let previousRegistrar: String?
584 let currentRegistrar: String?
585 let previousRegistrant: String?
586 let currentRegistrant: String?
587 let previousNameservers: [String]
588 let currentNameservers: [String]
589
590 nonisolated init(
591 id: UUID = UUID(),
592 date: Date,
593 summary: String,
594 previousRegistrar: String? = nil,
595 currentRegistrar: String? = nil,
596 previousRegistrant: String? = nil,
597 currentRegistrant: String? = nil,
598 previousNameservers: [String] = [],
599 currentNameservers: [String] = []
600 ) {
601 self.id = id
602 self.date = date
603 self.summary = summary
604 self.previousRegistrar = previousRegistrar
605 self.currentRegistrar = currentRegistrar
606 self.previousRegistrant = previousRegistrant
607 self.currentRegistrant = currentRegistrant
608 self.previousNameservers = previousNameservers
609 self.currentNameservers = currentNameservers
610 }
611}
612
613struct HostingTransitionEvent: Identifiable, Codable, Equatable, Sendable {
614 let id: UUID
615 let date: Date
616 let fromProvider: String
617 let toProvider: String
618 let summary: String
619
620 nonisolated init(id: UUID = UUID(), date: Date, fromProvider: String, toProvider: String, summary: String) {
621 self.id = id
622 self.date = date
623 self.fromProvider = fromProvider
624 self.toProvider = toProvider
625 self.summary = summary
626 }
627}
628
629struct SubdomainHistoryEntry: Identifiable, Codable, Equatable, Sendable {
630 let id: String
631 let hostname: String
632 let firstSeen: Date
633 let lastSeen: Date
634 let recurrenceCount: Int
635 let statusChangeCount: Int
636 let lastKnownStatus: String
637 let isEphemeral: Bool
638
639 nonisolated init(
640 hostname: String,
641 firstSeen: Date,
642 lastSeen: Date,
643 recurrenceCount: Int,
644 statusChangeCount: Int,
645 lastKnownStatus: String,
646 isEphemeral: Bool
647 ) {
648 id = hostname.lowercased()
649 self.hostname = hostname
650 self.firstSeen = firstSeen
651 self.lastSeen = lastSeen
652 self.recurrenceCount = recurrenceCount
653 self.statusChangeCount = statusChangeCount
654 self.lastKnownStatus = lastKnownStatus
655 self.isEphemeral = isEphemeral
656 }
657}
658
659struct IntelligenceRiskSignal: Identifiable, Codable, Equatable, Sendable {
660 let id: String
661 let title: String
662 let detail: String
663 let severity: ChangeSeverity
664 let firstObserved: Date?
665 let lastObserved: Date?
666
667 nonisolated init(
668 id: String,
669 title: String,
670 detail: String,
671 severity: ChangeSeverity,
672 firstObserved: Date? = nil,
673 lastObserved: Date? = nil
674 ) {
675 self.id = id
676 self.title = title
677 self.detail = detail
678 self.severity = severity
679 self.firstObserved = firstObserved
680 self.lastObserved = lastObserved
681 }
682}
683
684enum IntelligenceTimelineEventCategory: String, Codable, Sendable {
685 case ownership
686 case dns
687 case hosting
688 case subdomain
689 case classification
690 case risk
691}
692
693struct IntelligenceTimelineEvent: Identifiable, Codable, Equatable, Sendable {
694 let id: UUID
695 let date: Date
696 let category: IntelligenceTimelineEventCategory
697 let title: String
698 let detail: String
699 let severity: ChangeSeverity
700
701 nonisolated init(
702 id: UUID = UUID(),
703 date: Date,
704 category: IntelligenceTimelineEventCategory,
705 title: String,
706 detail: String,
707 severity: ChangeSeverity
708 ) {
709 self.id = id
710 self.date = date
711 self.category = category
712 self.title = title
713 self.detail = detail
714 self.severity = severity
715 }
716}
717
718struct DomainPricingInsight: Codable, Equatable, Sendable {
719 let estimatedPrice: String?
720 let premiumIndicator: Bool?
721 let resaleSignal: String?
722 let auctionSignal: String?
723 let source: String
724 let collectedAt: Date
725}
726
727enum HistoryDateFilter: String, CaseIterable, Identifiable {
728 case today
729 case last7Days
730 case all
731
732 var id: String { rawValue }
733
734 var title: String {
735 switch self {
736 case .today:
737 return "Today"
738 case .last7Days:
739 return "Last 7 Days"
740 case .all:
741 return "All"
742 }
743 }
744}
745
746enum ChangeFilterOption: String, CaseIterable, Identifiable {
747 case all
748 case changed
749 case unchanged
750
751 var id: String { rawValue }
752
753 var title: String {
754 switch self {
755 case .all:
756 return "All"
757 case .changed:
758 return "Changed"
759 case .unchanged:
760 return "Unchanged"
761 }
762 }
763}
764
765enum HistorySortOption: String, CaseIterable, Identifiable {
766 case newest
767 case oldest
768 case domain
769
770 var id: String { rawValue }
771
772 var title: String {
773 switch self {
774 case .newest:
775 return "Newest"
776 case .oldest:
777 return "Oldest"
778 case .domain:
779 return "Domain A-Z"
780 }
781 }
782}
783
784enum TimelineGroupingOption: String, CaseIterable, Identifiable {
785 case none
786 case relativeDay
787
788 var id: String { rawValue }
789
790 var title: String {
791 switch self {
792 case .none:
793 return "Ungrouped"
794 case .relativeDay:
795 return "Today / Yesterday / Older"
796 }
797 }
798}
799
800enum HistoryAutoPruneOption: String, CaseIterable, Codable, Identifiable {
801 case keep50
802 case keep100
803 case unlimited
804
805 var id: String { rawValue }
806
807 var title: String {
808 switch self {
809 case .keep50:
810 return "Keep Last 50"
811 case .keep100:
812 return "Keep Last 100"
813 case .unlimited:
814 return "Unlimited"
815 }
816 }
817
818 var keepCount: Int? {
819 switch self {
820 case .keep50:
821 return 50
822 case .keep100:
823 return 100
824 case .unlimited:
825 return nil
826 }
827 }
828}
829
830struct SnapshotSummary: Identifiable, Codable, Equatable {
831 let id: UUID
832 let domain: String
833 let timestamp: Date
834 let trackedDomainID: UUID?
835 let snapshotIndex: Int?
836 let previousSnapshotID: UUID?
837 let changeCount: Int
838 let severitySummary: ChangeSeverity?
839 let changeSummaryMessage: String?
840 let availability: DomainAvailabilityStatus?
841 let primaryIP: String?
842 let tlsStatus: String?
843 let riskScore: Int?
844 let historyEntryID: UUID
845
846 var hasChanges: Bool {
847 changeCount > 0
848 }
849}
850
851struct FullSnapshot: Codable {
852 let historyEntry: HistoryEntry
853}
854
855struct TimelineSection: Identifiable, Equatable {
856 let id: String
857 let title: String
858 let entries: [SnapshotSummary]
859}
860
861enum WatchlistFilterOption: String, CaseIterable, Identifiable {
862 case all
863 case pinnedOnly
864 case changedOnly
865
866 var id: String { rawValue }
867
868 var title: String {
869 switch self {
870 case .all:
871 return "All"
872 case .pinnedOnly:
873 return "Pinned Only"
874 case .changedOnly:
875 return "Changed Only"
876 }
877 }
878}
879
880enum WatchlistSortOption: String, CaseIterable, Identifiable {
881 case pinned
882 case recentlyUpdated
883 case alphabetical
884
885 var id: String { rawValue }
886
887 var title: String {
888 switch self {
889 case .pinned:
890 return "Pinned"
891 case .recentlyUpdated:
892 return "Recently Updated"
893 case .alphabetical:
894 return "Alphabetical"
895 }
896 }
897}
898
899enum PortfolioFilterOption: String, CaseIterable, Identifiable {
900 case all
901 case healthy
902 case warning
903 case critical
904 case changed
905 case expiring
906 case unreachable
907
908 var id: String { rawValue }
909
910 var title: String {
911 switch self {
912 case .all:
913 return "All"
914 case .healthy:
915 return "Healthy"
916 case .warning:
917 return "Warning"
918 case .critical:
919 return "Critical"
920 case .changed:
921 return "Changed"
922 case .expiring:
923 return "Expiring"
924 case .unreachable:
925 return "Unreachable"
926 }
927 }
928}
929
930extension DomainHealth {
931 static func classify(
932 certificateExpiryState: CertificateWarningLevel,
933 isReachable: Bool,
934 recentMonitoringFailureCount: Int,
935 hasRecentDNSChange: Bool,
936 instabilityScore: Int,
937 hasRecentCriticalChange: Bool,
938 hasInvalidTLS: Bool
939 ) -> DomainHealth {
940 if !isReachable
941 || hasInvalidTLS
942 || certificateExpiryState == .critical
943 || recentMonitoringFailureCount >= 2
944 || instabilityScore >= 70
945 || hasRecentCriticalChange {
946 return .critical
947 }
948
949 if certificateExpiryState == .warning
950 || recentMonitoringFailureCount == 1
951 || hasRecentDNSChange
952 || instabilityScore >= 35 {
953 return .warning
954 }
955
956 return .healthy
957 }
958
959 static func instabilityScore(
960 recentChangeCount: Int,
961 recentFailureCount: Int,
962 pendingAlertCount: Int,
963 hasRecentDNSChange: Bool
964 ) -> Int {
965 let rawScore = (recentChangeCount * 12)
966 + (recentFailureCount * 24)
967 + (pendingAlertCount * 8)
968 + (hasRecentDNSChange ? 14 : 0)
969 return min(rawScore, 100)
970 }
971}
972
973enum PremiumCapability: String, Codable {
974 case unlimitedTrackedDomains
975 case automatedMonitoring
976 case pushAlerts
977 case batchTracking
978 case advancedExports
979}
980
981enum CollaborationPermission: String, Codable, Equatable {
982 case readOnly
983 case editable
984
985 var title: String {
986 switch self {
987 case .readOnly:
988 return "Read only"
989 case .editable:
990 return "Editable"
991 }
992 }
993}
994
995enum CollaborationOwnership: String, Codable, Equatable {
996 case owner
997 case participant
998
999 var title: String {
1000 switch self {
1001 case .owner:
1002 return "Owned by you"
1003 case .participant:
1004 return "Shared with you"
1005 }
1006 }
1007}
1008
1009enum CollaborationScope: String, Codable, Equatable {
1010 case privateDatabase
1011 case sharedDatabase
1012}
1013
1014struct CollaborationMetadata: Codable, Equatable {
1015 var scope: CollaborationScope
1016 var ownership: CollaborationOwnership
1017 var permission: CollaborationPermission
1018 var shareRecordName: String?
1019
1020 init(
1021 scope: CollaborationScope,
1022 ownership: CollaborationOwnership,
1023 permission: CollaborationPermission,
1024 shareRecordName: String? = nil
1025 ) {
1026 self.scope = scope
1027 self.ownership = ownership
1028 self.permission = permission
1029 self.shareRecordName = shareRecordName
1030 }
1031
1032 var isShared: Bool {
1033 shareRecordName != nil || scope == .sharedDatabase
1034 }
1035
1036 var canEdit: Bool {
1037 permission == .editable
1038 }
1039
1040 var isOwner: Bool {
1041 ownership == .owner
1042 }
1043}
1044
1045enum MonitoringSensitivity: String, Codable, CaseIterable, Identifiable, Sendable {
1046 case low
1047 case medium
1048 case high
1049
1050 var id: String { rawValue }
1051
1052 var title: String {
1053 rawValue.capitalized
1054 }
1055
1056 var minimumInterval: TimeInterval {
1057 switch self {
1058 case .low:
1059 return 30 * 60
1060 case .medium:
1061 return 15 * 60
1062 case .high:
1063 return 5 * 60
1064 }
1065 }
1066
1067 var intervalMultiplier: Double {
1068 switch self {
1069 case .low:
1070 return 1.5
1071 case .medium:
1072 return 1.25
1073 case .high:
1074 return 1.1
1075 }
1076 }
1077}
1078
1079struct QuietHours: Codable, Equatable, Sendable {
1080 var startHour: Int
1081 var endHour: Int
1082
1083 init(startHour: Int, endHour: Int) {
1084 self.startHour = max(0, min(23, startHour))
1085 self.endHour = max(0, min(23, endHour))
1086 }
1087
1088 func contains(_ date: Date, calendar: Calendar = .current) -> Bool {
1089 let hour = calendar.component(.hour, from: date)
1090 if startHour <= endHour {
1091 return hour >= startHour && hour < endHour
1092 }
1093 return hour >= startHour || hour < endHour
1094 }
1095}
1096
1097enum MonitoringBaseInterval: String, Codable, CaseIterable, Identifiable {
1098 case thirtyMinutes
1099 case hourly
1100 case sixHours
1101 case twelveHours
1102 case daily
1103
1104 var id: String { rawValue }
1105
1106 var title: String {
1107 switch self {
1108 case .thirtyMinutes:
1109 return "Every 30 Minutes"
1110 case .hourly:
1111 return "Hourly"
1112 case .sixHours:
1113 return "Every 6 Hours"
1114 case .twelveHours:
1115 return "Every 12 Hours"
1116 case .daily:
1117 return "Daily"
1118 }
1119 }
1120
1121 var interval: TimeInterval {
1122 switch self {
1123 case .thirtyMinutes:
1124 return 30 * 60
1125 case .hourly:
1126 return 60 * 60
1127 case .sixHours:
1128 return 6 * 60 * 60
1129 case .twelveHours:
1130 return 12 * 60 * 60
1131 case .daily:
1132 return 24 * 60 * 60
1133 }
1134 }
1135
1136 static func nearest(to interval: TimeInterval) -> MonitoringBaseInterval {
1137 allCases.min { abs($0.interval - interval) < abs($1.interval - interval) } ?? .twelveHours
1138 }
1139}
1140
1141struct MonitoringConfig: Codable, Equatable, Sendable {
1142 var isEnabled: Bool
1143 var baseInterval: TimeInterval
1144 var adaptiveEnabled: Bool
1145 var sensitivity: MonitoringSensitivity
1146 var quietHours: QuietHours?
1147
1148 init(
1149 isEnabled: Bool = false,
1150 baseInterval: TimeInterval = MonitoringBaseInterval.twelveHours.interval,
1151 adaptiveEnabled: Bool = true,
1152 sensitivity: MonitoringSensitivity = .medium,
1153 quietHours: QuietHours? = nil
1154 ) {
1155 self.isEnabled = isEnabled
1156 self.baseInterval = baseInterval
1157 self.adaptiveEnabled = adaptiveEnabled
1158 self.sensitivity = sensitivity
1159 self.quietHours = quietHours
1160 }
1161
1162 var maxInterval: TimeInterval {
1163 24 * 60 * 60
1164 }
1165
1166 var sanitizedBaseInterval: TimeInterval {
1167 max(5 * 60, min(baseInterval, maxInterval))
1168 }
1169}
1170
1171struct MonitoringState: Codable, Equatable, Sendable {
1172 var lastCheck: Date?
1173 var lastChangeHash: String?
1174 var lastAlertDate: Date?
1175 var consecutiveStableChecks: Int
1176 var currentInterval: TimeInterval
1177 var lastChangeDate: Date?
1178
1179 init(
1180 lastCheck: Date? = nil,
1181 lastChangeHash: String? = nil,
1182 lastAlertDate: Date? = nil,
1183 consecutiveStableChecks: Int = 0,
1184 currentInterval: TimeInterval = 0,
1185 lastChangeDate: Date? = nil
1186 ) {
1187 self.lastCheck = lastCheck
1188 self.lastChangeHash = lastChangeHash
1189 self.lastAlertDate = lastAlertDate
1190 self.consecutiveStableChecks = consecutiveStableChecks
1191 self.currentInterval = currentInterval
1192 self.lastChangeDate = lastChangeDate
1193 }
1194}
1195
1196struct MonitoringPendingAlert: Codable, Identifiable, Equatable, Sendable {
1197 let id: UUID
1198 var detectedAt: Date
1199 var message: String
1200 var severity: MonitoringAlertSeverity
1201 var changeHash: String
1202
1203 init(
1204 id: UUID = UUID(),
1205 detectedAt: Date,
1206 message: String,
1207 severity: MonitoringAlertSeverity,
1208 changeHash: String
1209 ) {
1210 self.id = id
1211 self.detectedAt = detectedAt
1212 self.message = message
1213 self.severity = severity
1214 self.changeHash = changeHash
1215 }
1216}
1217
1218struct TrackedDomain: Codable, Identifiable, Equatable {
1219 let id: UUID
1220 var domain: String
1221 var createdAt: Date
1222 var updatedAt: Date
1223 var note: String?
1224 var isPinned: Bool
1225 var monitoringEnabled: Bool
1226 var lastKnownAvailability: DomainAvailabilityStatus?
1227 var lastSnapshotID: UUID?
1228 var lastChangeSummary: DomainChangeSummary?
1229 var lastChangeSeverity: ChangeSeverity?
1230 var certificateWarningLevel: CertificateWarningLevel
1231 var certificateDaysRemaining: Int?
1232 var lastMonitoredAt: Date?
1233 var lastAlertAt: Date?
1234 var monitoringState: MonitoringState
1235 var pendingMonitoringAlerts: [MonitoringPendingAlert]
1236 var collaboration: CollaborationMetadata?
1237
1238 init(
1239 id: UUID = UUID(),
1240 domain: String,
1241 createdAt: Date = Date(),
1242 updatedAt: Date = Date(),
1243 note: String? = nil,
1244 isPinned: Bool = false,
1245 monitoringEnabled: Bool = true,
1246 lastKnownAvailability: DomainAvailabilityStatus? = nil,
1247 lastSnapshotID: UUID? = nil,
1248 lastChangeSummary: DomainChangeSummary? = nil,
1249 lastChangeSeverity: ChangeSeverity? = nil,
1250 certificateWarningLevel: CertificateWarningLevel = .none,
1251 certificateDaysRemaining: Int? = nil,
1252 lastMonitoredAt: Date? = nil,
1253 lastAlertAt: Date? = nil,
1254 monitoringState: MonitoringState = MonitoringState(),
1255 pendingMonitoringAlerts: [MonitoringPendingAlert] = [],
1256 collaboration: CollaborationMetadata? = nil
1257 ) {
1258 self.id = id
1259 self.domain = domain
1260 self.createdAt = createdAt
1261 self.updatedAt = updatedAt
1262 self.note = note
1263 self.isPinned = isPinned
1264 self.monitoringEnabled = monitoringEnabled
1265 self.lastKnownAvailability = lastKnownAvailability
1266 self.lastSnapshotID = lastSnapshotID
1267 self.lastChangeSummary = lastChangeSummary
1268 self.lastChangeSeverity = lastChangeSeverity
1269 self.certificateWarningLevel = certificateWarningLevel
1270 self.certificateDaysRemaining = certificateDaysRemaining
1271 self.lastMonitoredAt = lastMonitoredAt
1272 self.lastAlertAt = lastAlertAt
1273 self.monitoringState = monitoringState
1274 self.pendingMonitoringAlerts = pendingMonitoringAlerts
1275 self.collaboration = collaboration
1276 }
1277
1278 init(from decoder: Decoder) throws {
1279 let container = try decoder.container(keyedBy: CodingKeys.self)
1280 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
1281 domain = try container.decode(String.self, forKey: .domain)
1282 createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
1283 updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
1284 note = try container.decodeIfPresent(String.self, forKey: .note)
1285 isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
1286 monitoringEnabled = try container.decodeIfPresent(Bool.self, forKey: .monitoringEnabled) ?? true
1287 lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
1288 lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
1289 lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
1290 lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
1291 certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
1292 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
1293 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
1294 lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt)
1295 monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState()
1296 pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? []
1297 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
1298 }
1299}
1300
1301enum MonitoringScope: String, Codable, CaseIterable, Identifiable {
1302 case allTracked
1303 case selectedOnly
1304
1305 var id: String { rawValue }
1306
1307 var title: String {
1308 switch self {
1309 case .allTracked:
1310 return "All Tracked"
1311 case .selectedOnly:
1312 return "Selected Only"
1313 }
1314 }
1315}
1316
1317enum MonitoringAlertSeverity: Int, Codable, CaseIterable, Comparable {
1318 case info
1319 case warning
1320 case critical
1321
1322 static func < (lhs: MonitoringAlertSeverity, rhs: MonitoringAlertSeverity) -> Bool {
1323 lhs.rawValue < rhs.rawValue
1324 }
1325
1326 var title: String {
1327 switch self {
1328 case .info:
1329 return "Info"
1330 case .warning:
1331 return "Warning"
1332 case .critical:
1333 return "Critical"
1334 }
1335 }
1336}
1337
1338enum MonitoringAlertFilter: String, Codable, CaseIterable, Identifiable {
1339 case criticalOnly
1340 case criticalAndWarnings
1341 case allChanges
1342
1343 var id: String { rawValue }
1344
1345 var title: String {
1346 switch self {
1347 case .criticalOnly:
1348 return "Critical Only"
1349 case .criticalAndWarnings:
1350 return "Critical + Warnings"
1351 case .allChanges:
1352 return "All Changes"
1353 }
1354 }
1355
1356 var minimumSeverity: MonitoringAlertSeverity {
1357 switch self {
1358 case .criticalOnly:
1359 return .critical
1360 case .criticalAndWarnings:
1361 return .warning
1362 case .allChanges:
1363 return .info
1364 }
1365 }
1366}
1367
1368enum MonitoringRunTrigger: String, Codable {
1369 case manual
1370 case background
1371 case cli
1372
1373 var title: String {
1374 switch self {
1375 case .manual:
1376 return "Manual"
1377 case .background:
1378 return "Background"
1379 case .cli:
1380 return "CLI"
1381 }
1382 }
1383}
1384
1385struct MonitoringSettings: Codable, Equatable {
1386 private enum CodingKeys: String, CodingKey {
1387 case config
1388 case isEnabled
1389 case frequency
1390 case scope
1391 case selectedDomainIDs
1392 case alertFilter
1393 case alertsEnabled
1394 }
1395
1396 var config: MonitoringConfig
1397 var scope: MonitoringScope
1398 var selectedDomainIDs: [UUID]
1399 var alertFilter: MonitoringAlertFilter
1400 var alertsEnabled: Bool
1401
1402 init(
1403 config: MonitoringConfig = MonitoringConfig(),
1404 scope: MonitoringScope = .allTracked,
1405 selectedDomainIDs: [UUID] = [],
1406 alertFilter: MonitoringAlertFilter = .criticalAndWarnings,
1407 alertsEnabled: Bool = false
1408 ) {
1409 self.config = config
1410 self.scope = scope
1411 self.selectedDomainIDs = selectedDomainIDs
1412 self.alertFilter = alertFilter
1413 self.alertsEnabled = alertsEnabled
1414 }
1415
1416 init(from decoder: Decoder) throws {
1417 let container = try decoder.container(keyedBy: CodingKeys.self)
1418 let legacyEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false
1419 let legacyFrequencyRaw = try container.decodeIfPresent(String.self, forKey: .frequency)
1420
1421 if let decodedConfig = try container.decodeIfPresent(MonitoringConfig.self, forKey: .config) {
1422 config = decodedConfig
1423 } else {
1424 let mappedInterval: TimeInterval
1425 switch legacyFrequencyRaw {
1426 case "daily":
1427 mappedInterval = MonitoringBaseInterval.daily.interval
1428 case "twiceDaily":
1429 mappedInterval = MonitoringBaseInterval.twelveHours.interval
1430 default:
1431 mappedInterval = MonitoringBaseInterval.twelveHours.interval
1432 }
1433
1434 config = MonitoringConfig(
1435 isEnabled: legacyEnabled,
1436 baseInterval: mappedInterval,
1437 adaptiveEnabled: true,
1438 sensitivity: .medium,
1439 quietHours: nil
1440 )
1441 }
1442
1443 scope = try container.decodeIfPresent(MonitoringScope.self, forKey: .scope) ?? .allTracked
1444 selectedDomainIDs = try container.decodeIfPresent([UUID].self, forKey: .selectedDomainIDs) ?? []
1445 alertFilter = try container.decodeIfPresent(MonitoringAlertFilter.self, forKey: .alertFilter) ?? .criticalAndWarnings
1446 alertsEnabled = try container.decodeIfPresent(Bool.self, forKey: .alertsEnabled) ?? false
1447 }
1448
1449 func encode(to encoder: Encoder) throws {
1450 var container = encoder.container(keyedBy: CodingKeys.self)
1451 try container.encode(config, forKey: .config)
1452 try container.encode(scope, forKey: .scope)
1453 try container.encode(selectedDomainIDs, forKey: .selectedDomainIDs)
1454 try container.encode(alertFilter, forKey: .alertFilter)
1455 try container.encode(alertsEnabled, forKey: .alertsEnabled)
1456 }
1457
1458 var isEnabled: Bool {
1459 get { config.isEnabled }
1460 set { config.isEnabled = newValue }
1461 }
1462
1463 var baseInterval: TimeInterval {
1464 get { config.baseInterval }
1465 set { config.baseInterval = newValue }
1466 }
1467
1468 var adaptiveEnabled: Bool {
1469 get { config.adaptiveEnabled }
1470 set { config.adaptiveEnabled = newValue }
1471 }
1472
1473 var sensitivity: MonitoringSensitivity {
1474 get { config.sensitivity }
1475 set { config.sensitivity = newValue }
1476 }
1477
1478 var quietHours: QuietHours? {
1479 get { config.quietHours }
1480 set { config.quietHours = newValue }
1481 }
1482}
1483
1484struct MonitoringDomainResult: Codable, Identifiable, Equatable {
1485 let id: UUID
1486 let domain: String
1487 let historyEntryID: UUID?
1488 let checkedAt: Date
1489 let didChange: Bool
1490 let summaryMessage: String
1491 let alertSeverity: MonitoringAlertSeverity?
1492 let certificateWarningLevel: CertificateWarningLevel
1493 let resultSource: LookupResultSource
1494 let errorMessage: String?
1495
1496 init(
1497 id: UUID = UUID(),
1498 domain: String,
1499 historyEntryID: UUID?,
1500 checkedAt: Date,
1501 didChange: Bool,
1502 summaryMessage: String,
1503 alertSeverity: MonitoringAlertSeverity?,
1504 certificateWarningLevel: CertificateWarningLevel,
1505 resultSource: LookupResultSource,
1506 errorMessage: String? = nil
1507 ) {
1508 self.id = id
1509 self.domain = domain
1510 self.historyEntryID = historyEntryID
1511 self.checkedAt = checkedAt
1512 self.didChange = didChange
1513 self.summaryMessage = summaryMessage
1514 self.alertSeverity = alertSeverity
1515 self.certificateWarningLevel = certificateWarningLevel
1516 self.resultSource = resultSource
1517 self.errorMessage = errorMessage
1518 }
1519}
1520
1521struct MonitoringLog: Codable, Identifiable, Equatable {
1522 let id: UUID
1523 let timestamp: Date
1524 let trigger: MonitoringRunTrigger
1525 let domainsChecked: Int
1526 let changesFound: Int
1527 let alertsTriggered: Int
1528 let checkedDomains: [MonitoringDomainResult]
1529 let errors: [String]
1530
1531 init(
1532 id: UUID = UUID(),
1533 timestamp: Date,
1534 trigger: MonitoringRunTrigger,
1535 domainsChecked: Int,
1536 changesFound: Int,
1537 alertsTriggered: Int,
1538 checkedDomains: [MonitoringDomainResult],
1539 errors: [String] = []
1540 ) {
1541 self.id = id
1542 self.timestamp = timestamp
1543 self.trigger = trigger
1544 self.domainsChecked = domainsChecked
1545 self.changesFound = changesFound
1546 self.alertsTriggered = alertsTriggered
1547 self.checkedDomains = checkedDomains
1548 self.errors = errors
1549 }
1550
1551 var summary: String {
1552 if errors.isEmpty {
1553 return "\(changesFound) changes across \(domainsChecked) domains"
1554 }
1555 return "\(changesFound) changes, \(errors.count) errors"
1556 }
1557}
1558
1559enum EventSeverity: String, Codable, CaseIterable, Comparable, Identifiable, Sendable {
1560 case info
1561 case warning
1562 case critical
1563
1564 var id: String { rawValue }
1565
1566 static func < (lhs: EventSeverity, rhs: EventSeverity) -> Bool {
1567 lhs.rank < rhs.rank
1568 }
1569
1570 var title: String {
1571 rawValue.capitalized
1572 }
1573
1574 private var rank: Int {
1575 switch self {
1576 case .info:
1577 return 0
1578 case .warning:
1579 return 1
1580 case .critical:
1581 return 2
1582 }
1583 }
1584
1585 init(monitoringSeverity: MonitoringAlertSeverity) {
1586 switch monitoringSeverity {
1587 case .info:
1588 self = .info
1589 case .warning:
1590 self = .warning
1591 case .critical:
1592 self = .critical
1593 }
1594 }
1595}
1596
1597enum MonitoringEventType: String, Codable, CaseIterable, Identifiable, Sendable {
1598 case dnsChanged
1599 case certificateUpdated
1600 case certificateExpiring
1601 case redirectChanged
1602 case headersChanged
1603 case endpointUnreachable
1604 case monitoringFailure
1605 case changeDetected
1606 case test
1607
1608 var id: String { rawValue }
1609
1610 var title: String {
1611 switch self {
1612 case .dnsChanged:
1613 return "DNS Changed"
1614 case .certificateUpdated:
1615 return "Certificate Updated"
1616 case .certificateExpiring:
1617 return "Certificate Expiring"
1618 case .redirectChanged:
1619 return "Redirect Changed"
1620 case .headersChanged:
1621 return "Headers Changed"
1622 case .endpointUnreachable:
1623 return "Endpoint Unreachable"
1624 case .monitoringFailure:
1625 return "Monitoring Failure"
1626 case .changeDetected:
1627 return "Change Detected"
1628 case .test:
1629 return "Test Event"
1630 }
1631 }
1632}
1633
1634struct MonitoringEvent: Codable, Identifiable, Equatable, Sendable {
1635 var id: UUID
1636 var type: MonitoringEventType
1637 var severity: EventSeverity
1638 var domain: String
1639 var timestamp: Date
1640 var summary: String
1641 var details: [String: String]
1642
1643 init(
1644 id: UUID = UUID(),
1645 type: MonitoringEventType,
1646 severity: EventSeverity,
1647 domain: String,
1648 timestamp: Date = Date(),
1649 summary: String,
1650 details: [String: String] = [:]
1651 ) {
1652 self.id = id
1653 self.type = type
1654 self.severity = severity
1655 self.domain = domain
1656 self.timestamp = timestamp
1657 self.summary = summary
1658 self.details = details
1659 }
1660}
1661
1662enum IntegrationType: String, Codable, CaseIterable, Identifiable, Sendable {
1663 case webhook
1664 case slack
1665 case email
1666
1667 var id: String { rawValue }
1668
1669 var title: String {
1670 rawValue.capitalized
1671 }
1672}
1673
1674enum SMTPSecurityMode: String, Codable, CaseIterable, Identifiable, Sendable {
1675 case plain
1676 case directTLS
1677
1678 var id: String { rawValue }
1679
1680 var title: String {
1681 switch self {
1682 case .plain:
1683 return "Plain"
1684 case .directTLS:
1685 return "Direct TLS"
1686 }
1687 }
1688}
1689
1690struct WebhookIntegrationConfiguration: Codable, Equatable, Sendable {
1691 var endpointDisplayHost: String
1692 var timeoutSeconds: Double
1693 var additionalHeaders: [String: String]
1694 var credentialReference: String?
1695
1696 init(
1697 endpointDisplayHost: String = "",
1698 timeoutSeconds: Double = 15,
1699 additionalHeaders: [String: String] = [:],
1700 credentialReference: String? = nil
1701 ) {
1702 self.endpointDisplayHost = endpointDisplayHost
1703 self.timeoutSeconds = timeoutSeconds
1704 self.additionalHeaders = additionalHeaders
1705 self.credentialReference = credentialReference
1706 }
1707}
1708
1709struct SlackIntegrationConfiguration: Codable, Equatable, Sendable {
1710 var destinationLabel: String
1711 var credentialReference: String?
1712
1713 init(
1714 destinationLabel: String = "Slack",
1715 credentialReference: String? = nil
1716 ) {
1717 self.destinationLabel = destinationLabel
1718 self.credentialReference = credentialReference
1719 }
1720}
1721
1722struct EmailIntegrationConfiguration: Codable, Equatable, Sendable {
1723 var smtpHost: String
1724 var port: Int
1725 var username: String
1726 var senderAddress: String
1727 var recipientAddresses: [String]
1728 var securityMode: SMTPSecurityMode
1729 var credentialReference: String?
1730
1731 init(
1732 smtpHost: String = "",
1733 port: Int = 465,
1734 username: String = "",
1735 senderAddress: String = "",
1736 recipientAddresses: [String] = [],
1737 securityMode: SMTPSecurityMode = .directTLS,
1738 credentialReference: String? = nil
1739 ) {
1740 self.smtpHost = smtpHost
1741 self.port = port
1742 self.username = username
1743 self.senderAddress = senderAddress
1744 self.recipientAddresses = recipientAddresses
1745 self.securityMode = securityMode
1746 self.credentialReference = credentialReference
1747 }
1748}
1749
1750enum IntegrationConfiguration: Codable, Equatable, Sendable {
1751 case webhook(WebhookIntegrationConfiguration)
1752 case slack(SlackIntegrationConfiguration)
1753 case email(EmailIntegrationConfiguration)
1754
1755 private enum CodingKeys: String, CodingKey {
1756 case type
1757 case webhook
1758 case slack
1759 case email
1760 }
1761
1762 init(from decoder: Decoder) throws {
1763 let container = try decoder.container(keyedBy: CodingKeys.self)
1764 let type = try container.decode(IntegrationType.self, forKey: .type)
1765 switch type {
1766 case .webhook:
1767 self = .webhook(try container.decode(WebhookIntegrationConfiguration.self, forKey: .webhook))
1768 case .slack:
1769 self = .slack(try container.decode(SlackIntegrationConfiguration.self, forKey: .slack))
1770 case .email:
1771 self = .email(try container.decode(EmailIntegrationConfiguration.self, forKey: .email))
1772 }
1773 }
1774
1775 func encode(to encoder: Encoder) throws {
1776 var container = encoder.container(keyedBy: CodingKeys.self)
1777 switch self {
1778 case .webhook(let configuration):
1779 try container.encode(IntegrationType.webhook, forKey: .type)
1780 try container.encode(configuration, forKey: .webhook)
1781 case .slack(let configuration):
1782 try container.encode(IntegrationType.slack, forKey: .type)
1783 try container.encode(configuration, forKey: .slack)
1784 case .email(let configuration):
1785 try container.encode(IntegrationType.email, forKey: .type)
1786 try container.encode(configuration, forKey: .email)
1787 }
1788 }
1789
1790 var type: IntegrationType {
1791 switch self {
1792 case .webhook:
1793 return .webhook
1794 case .slack:
1795 return .slack
1796 case .email:
1797 return .email
1798 }
1799 }
1800}
1801
1802struct IntegrationFilterSet: Codable, Equatable, Sendable {
1803 var minimumSeverity: EventSeverity
1804 var eventTypes: Set<MonitoringEventType>
1805 var domains: [String]
1806
1807 init(
1808 minimumSeverity: EventSeverity = .warning,
1809 eventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test }),
1810 domains: [String] = []
1811 ) {
1812 self.minimumSeverity = minimumSeverity
1813 self.eventTypes = eventTypes
1814 self.domains = domains
1815 }
1816
1817 func matches(_ event: MonitoringEvent) -> Bool {
1818 guard event.severity >= minimumSeverity else {
1819 return false
1820 }
1821 guard eventTypes.isEmpty || eventTypes.contains(event.type) else {
1822 return false
1823 }
1824 guard domains.isEmpty || domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) else {
1825 return false
1826 }
1827 return true
1828 }
1829}
1830
1831struct IntegrationTarget: Codable, Identifiable, Equatable, Sendable {
1832 var id: UUID
1833 var type: IntegrationType
1834 var name: String
1835 var isEnabled: Bool
1836 var configuration: IntegrationConfiguration
1837 var filters: IntegrationFilterSet
1838
1839 init(
1840 id: UUID = UUID(),
1841 type: IntegrationType,
1842 name: String,
1843 isEnabled: Bool = true,
1844 configuration: IntegrationConfiguration,
1845 filters: IntegrationFilterSet = IntegrationFilterSet()
1846 ) {
1847 self.id = id
1848 self.type = type
1849 self.name = name
1850 self.isEnabled = isEnabled
1851 self.configuration = configuration
1852 self.filters = filters
1853 }
1854}
1855
1856enum DeliveryStatus: String, Codable, CaseIterable, Identifiable, Sendable {
1857 case pending
1858 case retrying
1859 case delivered
1860 case failed
1861 case expired
1862 case skipped
1863
1864 var id: String { rawValue }
1865
1866 var title: String {
1867 rawValue.capitalized
1868 }
1869}
1870
1871struct DeliveryRecord: Codable, Identifiable, Equatable, Sendable {
1872 var id: UUID
1873 var integrationID: UUID
1874 var eventID: UUID
1875 var timestamp: Date
1876 var status: DeliveryStatus
1877 var destination: String
1878 var summary: String
1879 var failureReason: String?
1880 var attemptCount: Int
1881
1882 init(
1883 id: UUID = UUID(),
1884 integrationID: UUID,
1885 eventID: UUID,
1886 timestamp: Date = Date(),
1887 status: DeliveryStatus,
1888 destination: String,
1889 summary: String,
1890 failureReason: String? = nil,
1891 attemptCount: Int = 0
1892 ) {
1893 self.id = id
1894 self.integrationID = integrationID
1895 self.eventID = eventID
1896 self.timestamp = timestamp
1897 self.status = status
1898 self.destination = destination
1899 self.summary = summary
1900 self.failureReason = failureReason
1901 self.attemptCount = attemptCount
1902 }
1903}
1904
1905struct QueuedDelivery: Codable, Identifiable, Equatable, Sendable {
1906 var id: UUID
1907 var integrationID: UUID
1908 var event: MonitoringEvent
1909 var createdAt: Date
1910 var attemptCount: Int
1911 var nextAttemptAt: Date
1912 var lastError: String?
1913 var expiresAt: Date
1914
1915 init(
1916 id: UUID = UUID(),
1917 integrationID: UUID,
1918 event: MonitoringEvent,
1919 createdAt: Date = Date(),
1920 attemptCount: Int = 0,
1921 nextAttemptAt: Date = Date(),
1922 lastError: String? = nil,
1923 expiresAt: Date = Date().addingTimeInterval(3 * 24 * 60 * 60)
1924 ) {
1925 self.id = id
1926 self.integrationID = integrationID
1927 self.event = event
1928 self.createdAt = createdAt
1929 self.attemptCount = attemptCount
1930 self.nextAttemptAt = nextAttemptAt
1931 self.lastError = lastError
1932 self.expiresAt = expiresAt
1933 }
1934}
1935
1936// MARK: - DNS Models
1937
1938enum DNSRecordType: String, CaseIterable, Codable {
1939 case A
1940 case AAAA
1941 case MX
1942 case NS
1943 case TXT
1944 case CNAME
1945 case SOA
1946 case SRV
1947 case CAA
1948 case DS
1949 case PTR
1950
1951 var queryType: Int {
1952 switch self {
1953 case .A: return 1
1954 case .AAAA: return 28
1955 case .MX: return 15
1956 case .NS: return 2
1957 case .TXT: return 16
1958 case .CNAME: return 5
1959 case .SOA: return 6
1960 case .SRV: return 33
1961 case .CAA: return 257
1962 case .DS: return 43
1963 case .PTR: return 12
1964 }
1965 }
1966
1967 var usesRawDataValue: Bool {
1968 switch self {
1969 case .TXT, .SOA, .DS:
1970 return true
1971 default:
1972 return false
1973 }
1974 }
1975}
1976
1977struct DNSRecord: Identifiable, Codable {
1978 var id = UUID()
1979 let value: String
1980 let ttl: Int
1981}
1982
1983struct DNSSection: Identifiable, Codable {
1984 var id = UUID()
1985 let recordType: DNSRecordType
1986 var records: [DNSRecord]
1987 var wildcardRecords: [DNSRecord] = []
1988 var dnssecSigned: Bool?
1989 var error: String?
1990}
1991
1992// MARK: - SSL Models
1993
1994struct SSLCertificateInfo: Codable {
1995 struct CertChainEntry: Codable {
1996 let subject: String
1997 let issuer: String
1998 }
1999
2000 let commonName: String
2001 let subjectAltNames: [String]
2002 let issuer: String
2003 let validFrom: Date
2004 let validUntil: Date
2005 let daysUntilExpiry: Int
2006 let chainDepth: Int
2007 let tlsVersion: String?
2008 let cipherSuite: String?
2009 let chain: [CertChainEntry]
2010
2011 init(
2012 commonName: String,
2013 subjectAltNames: [String],
2014 issuer: String,
2015 validFrom: Date,
2016 validUntil: Date,
2017 daysUntilExpiry: Int,
2018 chainDepth: Int,
2019 tlsVersion: String? = nil,
2020 cipherSuite: String? = nil,
2021 chain: [CertChainEntry] = []
2022 ) {
2023 self.commonName = commonName
2024 self.subjectAltNames = subjectAltNames
2025 self.issuer = issuer
2026 self.validFrom = validFrom
2027 self.validUntil = validUntil
2028 self.daysUntilExpiry = daysUntilExpiry
2029 self.chainDepth = chainDepth
2030 self.tlsVersion = tlsVersion
2031 self.cipherSuite = cipherSuite
2032 self.chain = chain
2033 }
2034
2035 init(from decoder: Decoder) throws {
2036 let container = try decoder.container(keyedBy: CodingKeys.self)
2037 commonName = try container.decode(String.self, forKey: .commonName)
2038 subjectAltNames = try container.decode([String].self, forKey: .subjectAltNames)
2039 issuer = try container.decode(String.self, forKey: .issuer)
2040 validFrom = try container.decode(Date.self, forKey: .validFrom)
2041 validUntil = try container.decode(Date.self, forKey: .validUntil)
2042 daysUntilExpiry = try container.decode(Int.self, forKey: .daysUntilExpiry)
2043 chainDepth = try container.decode(Int.self, forKey: .chainDepth)
2044 tlsVersion = try container.decodeIfPresent(String.self, forKey: .tlsVersion)
2045 cipherSuite = try container.decodeIfPresent(String.self, forKey: .cipherSuite)
2046 chain = try container.decodeIfPresent([CertChainEntry].self, forKey: .chain) ?? []
2047 }
2048}
2049
2050// MARK: - HTTP Headers Models
2051
2052struct HTTPHeader: Identifiable, Codable {
2053 var id = UUID()
2054 let name: String
2055 let value: String
2056
2057 static let securityHeaders: Set<String> = [
2058 "strict-transport-security",
2059 "x-frame-options",
2060 "x-content-type-options",
2061 "content-security-policy",
2062 "referrer-policy"
2063 ]
2064
2065 var isSecurityHeader: Bool {
2066 Self.securityHeaders.contains(name.lowercased())
2067 }
2068}
2069
2070// MARK: - Reachability Models
2071
2072struct PortReachability: Identifiable, Codable {
2073 var id = UUID()
2074 let port: UInt16
2075 let reachable: Bool
2076 let latencyMs: Int?
2077}
2078
2079// MARK: - IP Geolocation Models
2080
2081struct IPGeolocation: Codable {
2082 let ip: String
2083 let city: String?
2084 let region: String?
2085 let country_name: String?
2086 let org: String?
2087 let latitude: Double?
2088 let longitude: Double?
2089}
2090
2091// MARK: - Ownership Models
2092
2093struct DomainOwnership: Codable, Equatable {
2094 let registrar: String?
2095 let registrant: String?
2096 let createdDate: Date?
2097 let expirationDate: Date?
2098 let status: [String]
2099 let nameservers: [String]
2100 let abuseEmail: String?
2101
2102 init(
2103 registrar: String? = nil,
2104 registrant: String? = nil,
2105 createdDate: Date? = nil,
2106 expirationDate: Date? = nil,
2107 status: [String] = [],
2108 nameservers: [String] = [],
2109 abuseEmail: String? = nil
2110 ) {
2111 self.registrar = registrar
2112 self.registrant = registrant
2113 self.createdDate = createdDate
2114 self.expirationDate = expirationDate
2115 self.status = status
2116 self.nameservers = nameservers
2117 self.abuseEmail = abuseEmail
2118 }
2119
2120 init(from decoder: Decoder) throws {
2121 let container = try decoder.container(keyedBy: CodingKeys.self)
2122 registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
2123 registrant = try container.decodeIfPresent(String.self, forKey: .registrant)
2124 createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
2125 expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
2126 status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
2127 nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
2128 abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail)
2129 }
2130}
2131
2132struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable {
2133 var id: String { hostname }
2134 let hostname: String
2135 let source: String?
2136 let isExtended: Bool
2137
2138 nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) {
2139 self.hostname = hostname
2140 self.source = source
2141 self.isExtended = isExtended
2142 }
2143}
2144
2145// MARK: - Email Security Models
2146
2147struct EmailSecurityResult: Codable {
2148 let spf: EmailSecurityRecord
2149 let dmarc: EmailSecurityRecord
2150 let dkim: EmailSecurityRecord
2151 let bimi: EmailSecurityRecord
2152 let mtaSts: MTASTSResult?
2153
2154 init(
2155 spf: EmailSecurityRecord,
2156 dmarc: EmailSecurityRecord,
2157 dkim: EmailSecurityRecord,
2158 bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil),
2159 mtaSts: MTASTSResult? = nil
2160 ) {
2161 self.spf = spf
2162 self.dmarc = dmarc
2163 self.dkim = dkim
2164 self.bimi = bimi
2165 self.mtaSts = mtaSts
2166 }
2167
2168 init(from decoder: Decoder) throws {
2169 let container = try decoder.container(keyedBy: CodingKeys.self)
2170 spf = try container.decode(EmailSecurityRecord.self, forKey: .spf)
2171 dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc)
2172 dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim)
2173 bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi)
2174 ?? EmailSecurityRecord(found: false, value: nil)
2175 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts)
2176 }
2177}
2178
2179struct EmailSecurityRecord: Codable {
2180 let found: Bool
2181 let value: String?
2182 let matchedSelector: String?
2183
2184 init(found: Bool, value: String?, matchedSelector: String? = nil) {
2185 self.found = found
2186 self.value = value
2187 self.matchedSelector = matchedSelector
2188 }
2189
2190 init(from decoder: Decoder) throws {
2191 let container = try decoder.container(keyedBy: CodingKeys.self)
2192 found = try container.decode(Bool.self, forKey: .found)
2193 value = try container.decodeIfPresent(String.self, forKey: .value)
2194 matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector)
2195 }
2196}
2197
2198struct MTASTSResult: Codable {
2199 let txtFound: Bool
2200 let policyMode: String?
2201}
2202
2203// MARK: - Redirect Chain Models
2204
2205struct RedirectHop: Identifiable, Codable {
2206 var id = UUID()
2207 let stepNumber: Int
2208 let statusCode: Int
2209 let url: String
2210 let isFinal: Bool
2211}
2212
2213// MARK: - Port Scan Models
2214
2215enum PortScanKind: String, Codable {
2216 case standard
2217 case custom
2218}
2219
2220struct PortScanResult: Identifiable, Codable {
2221 var id = UUID()
2222 let port: UInt16
2223 let service: String
2224 let open: Bool
2225 var banner: String?
2226 let kind: PortScanKind
2227 let durationMs: Int?
2228
2229 nonisolated init(
2230 port: UInt16,
2231 service: String,
2232 open: Bool,
2233 banner: String? = nil,
2234 kind: PortScanKind = .standard,
2235 durationMs: Int? = nil
2236 ) {
2237 self.port = port
2238 self.service = service
2239 self.open = open
2240 self.banner = banner
2241 self.kind = kind
2242 self.durationMs = durationMs
2243 }
2244
2245 init(from decoder: Decoder) throws {
2246 let container = try decoder.container(keyedBy: CodingKeys.self)
2247 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2248 port = try container.decode(UInt16.self, forKey: .port)
2249 service = try container.decode(String.self, forKey: .service)
2250 open = try container.decode(Bool.self, forKey: .open)
2251 banner = try container.decodeIfPresent(String.self, forKey: .banner)
2252 kind = try container.decodeIfPresent(PortScanKind.self, forKey: .kind) ?? .standard
2253 durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs)
2254 }
2255}
2256
2257// MARK: - History Models
2258
2259struct HistoryEntry: Identifiable, Codable {
2260 var id = UUID()
2261 let domain: String
2262 let timestamp: Date
2263 var trackedDomainID: UUID?
2264 var note: String?
2265 let dnsSections: [DNSSection]
2266 let sslInfo: SSLCertificateInfo?
2267 let httpHeaders: [HTTPHeader]
2268 let reachabilityResults: [PortReachability]
2269 let ipGeolocation: IPGeolocation?
2270 var emailSecurity: EmailSecurityResult?
2271 var mtaSts: MTASTSResult?
2272 var ownership: DomainOwnership?
2273 var ownershipHistory: [DomainOwnershipHistoryEvent]
2274 var inferredProvider: InferredProviderFingerprint?
2275 var priorProviders: [String]
2276 var domainClassification: DomainClassificationSummary?
2277 var ownershipTransitions: [OwnershipTransitionEvent]
2278 var hostingTransitions: [HostingTransitionEvent]
2279 var subdomainHistory: [SubdomainHistoryEntry]
2280 var riskSignals: [IntelligenceRiskSignal]
2281 var intelligenceTimeline: [IntelligenceTimelineEvent]
2282 var ptrRecord: String?
2283 var redirectChain: [RedirectHop]
2284 var subdomains: [DiscoveredSubdomain]
2285 var extendedSubdomains: [DiscoveredSubdomain]
2286 var dnsHistory: [DNSHistoryEvent]
2287 var domainPricing: DomainPricingInsight?
2288 var portScanResults: [PortScanResult]
2289 var hstsPreloaded: Bool?
2290 var availabilityResult: DomainAvailabilityResult?
2291 var suggestions: [DomainSuggestionResult]
2292 var appVersion: String
2293 var resultSource: LookupResultSource
2294 var dataSources: [String]
2295 var provenanceBySection: [LookupSectionKind: SectionProvenance]
2296 var availabilityConfidence: ConfidenceLevel?
2297 var ownershipConfidence: ConfidenceLevel?
2298 var subdomainConfidence: ConfidenceLevel?
2299 var emailSecurityConfidence: ConfidenceLevel?
2300 var geolocationConfidence: ConfidenceLevel?
2301 var errorDetails: [LookupSectionKind: InspectionFailure]
2302 var isPartialSnapshot: Bool
2303 var validationIssues: [String]
2304 var resolverDisplayName: String
2305 var resolverURLString: String
2306 var totalLookupDurationMs: Int?
2307 var primaryIP: String?
2308 var finalRedirectURL: String?
2309 var tlsStatusSummary: String?
2310 var emailSecuritySummary: String?
2311 var httpGradeSummary: String?
2312 var changeSummary: DomainChangeSummary?
2313 var snapshotIndex: Int?
2314 var previousSnapshotID: UUID?
2315 var changeCount: Int
2316 var severitySummary: ChangeSeverity?
2317 var sslError: String?
2318 var httpHeadersError: String?
2319 var reachabilityError: String?
2320 var ipGeolocationError: String?
2321 var emailSecurityError: String?
2322 var ownershipError: String?
2323 var ownershipHistoryError: String?
2324 var ptrError: String?
2325 var redirectChainError: String?
2326 var subdomainsError: String?
2327 var extendedSubdomainsError: String?
2328 var dnsHistoryError: String?
2329 var domainPricingError: String?
2330 var portScanError: String?
2331
2332 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
2333 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
2334 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
2335 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
2336 ownershipHistory: [DomainOwnershipHistoryEvent] = [],
2337 inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [],
2338 domainClassification: DomainClassificationSummary? = nil,
2339 ownershipTransitions: [OwnershipTransitionEvent] = [],
2340 hostingTransitions: [HostingTransitionEvent] = [],
2341 subdomainHistory: [SubdomainHistoryEntry] = [],
2342 riskSignals: [IntelligenceRiskSignal] = [],
2343 intelligenceTimeline: [IntelligenceTimelineEvent] = [],
2344 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
2345 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
2346 domainPricing: DomainPricingInsight? = nil,
2347 portScanResults: [PortScanResult] = [],
2348 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
2349 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
2350 resultSource: LookupResultSource = .snapshot, dataSources: [String] = [],
2351 provenanceBySection: [LookupSectionKind: SectionProvenance] = [:],
2352 availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil,
2353 subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil,
2354 geolocationConfidence: ConfidenceLevel? = nil,
2355 errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false,
2356 validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String,
2357 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
2358 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
2359 changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil,
2360 changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
2361 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
2362 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
2363 ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
2364 extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
2365 domainPricingError: String? = nil, portScanError: String? = nil) {
2366 self.domain = domain
2367 self.timestamp = timestamp
2368 self.trackedDomainID = trackedDomainID
2369 self.note = note
2370 self.dnsSections = dnsSections
2371 self.sslInfo = sslInfo
2372 self.httpHeaders = httpHeaders
2373 self.reachabilityResults = reachabilityResults
2374 self.ipGeolocation = ipGeolocation
2375 self.emailSecurity = emailSecurity
2376 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
2377 self.ownership = ownership
2378 self.ownershipHistory = ownershipHistory
2379 self.inferredProvider = inferredProvider
2380 self.priorProviders = priorProviders
2381 self.domainClassification = domainClassification
2382 self.ownershipTransitions = ownershipTransitions
2383 self.hostingTransitions = hostingTransitions
2384 self.subdomainHistory = subdomainHistory
2385 self.riskSignals = riskSignals
2386 self.intelligenceTimeline = intelligenceTimeline
2387 self.ptrRecord = ptrRecord
2388 self.redirectChain = redirectChain
2389 self.subdomains = subdomains
2390 self.extendedSubdomains = extendedSubdomains
2391 self.dnsHistory = dnsHistory
2392 self.domainPricing = domainPricing
2393 self.portScanResults = portScanResults
2394 self.hstsPreloaded = hstsPreloaded
2395 self.availabilityResult = availabilityResult
2396 self.suggestions = suggestions
2397 self.appVersion = appVersion
2398 self.resultSource = resultSource
2399 self.dataSources = dataSources
2400 self.provenanceBySection = provenanceBySection
2401 self.availabilityConfidence = availabilityConfidence
2402 self.ownershipConfidence = ownershipConfidence
2403 self.subdomainConfidence = subdomainConfidence
2404 self.emailSecurityConfidence = emailSecurityConfidence
2405 self.geolocationConfidence = geolocationConfidence
2406 self.errorDetails = errorDetails
2407 self.isPartialSnapshot = isPartialSnapshot
2408 self.validationIssues = validationIssues
2409 self.resolverDisplayName = resolverDisplayName
2410 self.resolverURLString = resolverURLString
2411 self.totalLookupDurationMs = totalLookupDurationMs
2412 self.primaryIP = primaryIP
2413 self.finalRedirectURL = finalRedirectURL
2414 self.tlsStatusSummary = tlsStatusSummary
2415 self.emailSecuritySummary = emailSecuritySummary
2416 self.httpGradeSummary = httpGradeSummary
2417 self.changeSummary = changeSummary
2418 self.snapshotIndex = snapshotIndex
2419 self.previousSnapshotID = previousSnapshotID
2420 self.changeCount = changeCount
2421 self.severitySummary = severitySummary
2422 self.sslError = sslError
2423 self.httpHeadersError = httpHeadersError
2424 self.reachabilityError = reachabilityError
2425 self.ipGeolocationError = ipGeolocationError
2426 self.emailSecurityError = emailSecurityError
2427 self.ownershipError = ownershipError
2428 self.ownershipHistoryError = ownershipHistoryError
2429 self.ptrError = ptrError
2430 self.redirectChainError = redirectChainError
2431 self.subdomainsError = subdomainsError
2432 self.extendedSubdomainsError = extendedSubdomainsError
2433 self.dnsHistoryError = dnsHistoryError
2434 self.domainPricingError = domainPricingError
2435 self.portScanError = portScanError
2436 }
2437
2438 init(from decoder: Decoder) throws {
2439 let container = try decoder.container(keyedBy: CodingKeys.self)
2440 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2441 domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain"
2442 timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast
2443 trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID)
2444 note = try container.decodeIfPresent(String.self, forKey: .note)
2445 dnsSections = try container.decodeIfPresent([DNSSection].self, forKey: .dnsSections) ?? []
2446 sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
2447 httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? []
2448 reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? []
2449 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
2450 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
2451 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
2452 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
2453 ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
2454 inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider)
2455 priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? []
2456 domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification)
2457 ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? []
2458 hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? []
2459 subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? []
2460 riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? []
2461 intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? []
2462 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
2463 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
2464 subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
2465 extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
2466 dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
2467 domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
2468 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
2469 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
2470 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
2471 suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? []
2472 appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "2.6.0"
2473 resultSource = try container.decodeIfPresent(LookupResultSource.self, forKey: .resultSource) ?? .snapshot
2474 dataSources = try container.decodeIfPresent([String].self, forKey: .dataSources) ?? []
2475 provenanceBySection = try container.decodeIfPresent([LookupSectionKind: SectionProvenance].self, forKey: .provenanceBySection) ?? [:]
2476 availabilityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .availabilityConfidence)
2477 ownershipConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .ownershipConfidence)
2478 subdomainConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .subdomainConfidence)
2479 emailSecurityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .emailSecurityConfidence)
2480 geolocationConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .geolocationConfidence)
2481 errorDetails = try container.decodeIfPresent([LookupSectionKind: InspectionFailure].self, forKey: .errorDetails) ?? [:]
2482 validationIssues = try container.decodeIfPresent([String].self, forKey: .validationIssues) ?? HistoryEntry.defaultValidationIssues(domain: domain, timestamp: timestamp)
2483 isPartialSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isPartialSnapshot) ?? !validationIssues.isEmpty
2484 resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
2485 resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
2486 totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
2487 primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP)
2488 finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL)
2489 tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary)
2490 emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
2491 httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
2492 changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
2493 snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex)
2494 previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID)
2495 changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount)
2496 ?? changeSummary?.changedSections.count
2497 ?? 0
2498 severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary)
2499 ?? changeSummary?.severity
2500 sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
2501 httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
2502 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
2503 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
2504 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
2505 ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
2506 ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError)
2507 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
2508 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
2509 subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
2510 extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
2511 dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
2512 domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
2513 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
2514 }
2515
2516 private static func defaultValidationIssues(domain: String, timestamp: Date) -> [String] {
2517 var issues: [String] = []
2518 if domain == "unknown-domain" {
2519 issues.append("Missing domain in stored snapshot")
2520 }
2521 if timestamp == .distantPast {
2522 issues.append("Missing collection timestamp in stored snapshot")
2523 }
2524 return issues
2525 }
2526
2527 var snapshotSummary: SnapshotSummary {
2528 SnapshotSummary(
2529 id: id,
2530 domain: domain,
2531 timestamp: timestamp,
2532 trackedDomainID: trackedDomainID,
2533 snapshotIndex: snapshotIndex,
2534 previousSnapshotID: previousSnapshotID,
2535 changeCount: changeCount,
2536 severitySummary: severitySummary ?? changeSummary?.severity,
2537 changeSummaryMessage: changeSummary?.message,
2538 availability: availabilityResult?.status,
2539 primaryIP: primaryIP,
2540 tlsStatus: tlsStatusSummary,
2541 riskScore: changeSummary?.riskAssessment?.score,
2542 historyEntryID: id
2543 )
2544 }
2545}
2546
2547// MARK: - Cloudflare DNS-over-HTTPS Response
2548
2549struct CloudflareDNSResponse: Decodable {
2550 let Status: Int
2551 let AD: Bool?
2552 let Answer: [CloudflareDNSAnswer]?
2553
2554 struct CloudflareDNSAnswer: Decodable {
2555 let name: String
2556 let type: Int
2557 let TTL: Int
2558 let data: String
2559 }
2560}