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