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