krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.3: 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
1270
1271// Declared in an extension so the struct keeps its synthesized memberwise
1272// initializer: adding one inside the body would suppress it.
1273extension HistoryEntry.Inspection {
1274 /// Every field here is copied straight off the snapshot, and both call
1275 /// sites did exactly that. Naming it once keeps them from drifting.
1276 init(snapshot: LookupSnapshot) {
1277 self.init(
1278 dnsSections: snapshot.dnsSections,
1279 sslInfo: snapshot.sslInfo,
1280 httpHeaders: snapshot.httpHeaders,
1281 reachabilityResults: snapshot.reachabilityResults,
1282 ipGeolocation: snapshot.ipGeolocation,
1283 emailSecurity: snapshot.emailSecurity,
1284 mtaSts: snapshot.emailSecurity?.mtaSts,
1285 ptrRecord: snapshot.ptrRecord,
1286 redirectChain: snapshot.redirectChain,
1287 subdomains: snapshot.subdomains,
1288 extendedSubdomains: snapshot.extendedSubdomains,
1289 dnsHistory: snapshot.dnsHistory,
1290 portScanResults: snapshot.portScanResults,
1291 hstsPreloaded: snapshot.hstsPreloaded,
1292 availabilityResult: snapshot.availabilityResult,
1293 suggestions: snapshot.suggestions
1294 )
1295 }
1296}
1297
1298// Declared in an extension so the struct keeps its synthesized memberwise
1299// initializer: adding one inside the body would suppress it.
1300extension HistoryEntry.Provenance {
1301 /// Every field here is copied straight off the snapshot, and both call
1302 /// sites did exactly that. Naming it once keeps them from drifting.
1303 init(snapshot: LookupSnapshot) {
1304 self.init(
1305 appVersion: snapshot.appVersion,
1306 resultSource: snapshot.resultSource,
1307 dataSources: snapshot.dataSources,
1308 provenanceBySection: snapshot.provenanceBySection,
1309 availabilityConfidence: snapshot.availabilityConfidence,
1310 ownershipConfidence: snapshot.ownershipConfidence,
1311 subdomainConfidence: snapshot.subdomainConfidence,
1312 emailSecurityConfidence: snapshot.emailSecurityConfidence,
1313 geolocationConfidence: snapshot.geolocationConfidence,
1314 isPartialSnapshot: snapshot.isPartialSnapshot,
1315 validationIssues: snapshot.validationIssues,
1316 resolverDisplayName: snapshot.resolverDisplayName,
1317 resolverURLString: snapshot.resolverURLString,
1318 totalLookupDurationMs: snapshot.totalLookupDurationMs
1319 )
1320 }
1321}
1322
1323// Declared in an extension so the struct keeps its synthesized memberwise
1324// initializer: adding one inside the body would suppress it.
1325extension HistoryEntry.Failures {
1326 /// Every field here is copied straight off the snapshot, and both call
1327 /// sites did exactly that. Naming it once keeps them from drifting.
1328 init(snapshot: LookupSnapshot) {
1329 self.init(
1330 errorDetails: snapshot.errorDetails,
1331 sslError: snapshot.sslError,
1332 httpHeadersError: snapshot.httpHeadersError,
1333 reachabilityError: snapshot.reachabilityError,
1334 ipGeolocationError: snapshot.ipGeolocationError,
1335 emailSecurityError: snapshot.emailSecurityError,
1336 ownershipError: snapshot.ownershipError,
1337 ownershipHistoryError: snapshot.ownershipHistoryError,
1338 ptrError: snapshot.ptrError,
1339 redirectChainError: snapshot.redirectChainError,
1340 subdomainsError: snapshot.subdomainsError,
1341 extendedSubdomainsError: snapshot.extendedSubdomainsError,
1342 dnsHistoryError: snapshot.dnsHistoryError,
1343 domainPricingError: snapshot.domainPricingError,
1344 reputationError: snapshot.reputationError,
1345 portScanError: snapshot.portScanError
1346 )
1347 }
1348}
1349
1350struct TrackedDomain: Codable, Identifiable, Equatable {
1351 let id: UUID
1352 var domain: String
1353 var createdAt: Date
1354 var updatedAt: Date
1355 var note: String?
1356 var isPinned: Bool
1357 var monitoringEnabled: Bool
1358 var lastKnownAvailability: DomainAvailabilityStatus?
1359 var lastSnapshotID: UUID?
1360 var lastChangeSummary: DomainChangeSummary?
1361 var lastChangeSeverity: ChangeSeverity?
1362 var certificateWarningLevel: CertificateWarningLevel
1363 var certificateDaysRemaining: Int?
1364 var lastMonitoredAt: Date?
1365 var lastAlertAt: Date?
1366 var monitoringState: MonitoringState
1367 var pendingMonitoringAlerts: [MonitoringPendingAlert]
1368 var collaboration: CollaborationMetadata?
1369 var tags: [String]
1370
1371 init(
1372 id: UUID = UUID(),
1373 domain: String,
1374 createdAt: Date = Date(),
1375 updatedAt: Date = Date(),
1376 note: String? = nil,
1377 isPinned: Bool = false,
1378 monitoringEnabled: Bool = true,
1379 lastKnownAvailability: DomainAvailabilityStatus? = nil,
1380 lastSnapshotID: UUID? = nil,
1381 lastChangeSummary: DomainChangeSummary? = nil,
1382 lastChangeSeverity: ChangeSeverity? = nil,
1383 certificateWarningLevel: CertificateWarningLevel = .none,
1384 certificateDaysRemaining: Int? = nil,
1385 lastMonitoredAt: Date? = nil,
1386 lastAlertAt: Date? = nil,
1387 monitoringState: MonitoringState = MonitoringState(),
1388 pendingMonitoringAlerts: [MonitoringPendingAlert] = [],
1389 collaboration: CollaborationMetadata? = nil,
1390 tags: [String] = []
1391 ) {
1392 self.id = id
1393 self.domain = domain
1394 self.createdAt = createdAt
1395 self.updatedAt = updatedAt
1396 self.note = note
1397 self.isPinned = isPinned
1398 self.monitoringEnabled = monitoringEnabled
1399 self.lastKnownAvailability = lastKnownAvailability
1400 self.lastSnapshotID = lastSnapshotID
1401 self.lastChangeSummary = lastChangeSummary
1402 self.lastChangeSeverity = lastChangeSeverity
1403 self.certificateWarningLevel = certificateWarningLevel
1404 self.certificateDaysRemaining = certificateDaysRemaining
1405 self.lastMonitoredAt = lastMonitoredAt
1406 self.lastAlertAt = lastAlertAt
1407 self.monitoringState = monitoringState
1408 self.pendingMonitoringAlerts = pendingMonitoringAlerts
1409 self.collaboration = collaboration
1410 self.tags = tags
1411 }
1412
1413 init(from decoder: Decoder) throws {
1414 let container = try decoder.container(keyedBy: CodingKeys.self)
1415 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
1416 domain = try container.decode(String.self, forKey: .domain)
1417 createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
1418 updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
1419 note = try container.decodeIfPresent(String.self, forKey: .note)
1420 isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
1421 monitoringEnabled = try container.decodeIfPresent(Bool.self, forKey: .monitoringEnabled) ?? true
1422 lastKnownAvailability = try container.decodeIfPresent(DomainAvailabilityStatus.self, forKey: .lastKnownAvailability)
1423 lastSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .lastSnapshotID)
1424 lastChangeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .lastChangeSummary)
1425 lastChangeSeverity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .lastChangeSeverity) ?? lastChangeSummary?.severity
1426 certificateWarningLevel = try container.decodeIfPresent(CertificateWarningLevel.self, forKey: .certificateWarningLevel) ?? .none
1427 certificateDaysRemaining = try container.decodeIfPresent(Int.self, forKey: .certificateDaysRemaining)
1428 lastMonitoredAt = try container.decodeIfPresent(Date.self, forKey: .lastMonitoredAt)
1429 lastAlertAt = try container.decodeIfPresent(Date.self, forKey: .lastAlertAt)
1430 monitoringState = try container.decodeIfPresent(MonitoringState.self, forKey: .monitoringState) ?? MonitoringState()
1431 pendingMonitoringAlerts = try container.decodeIfPresent([MonitoringPendingAlert].self, forKey: .pendingMonitoringAlerts) ?? []
1432 collaboration = try container.decodeIfPresent(CollaborationMetadata.self, forKey: .collaboration)
1433 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
1434 }
1435}
1436
1437enum MonitoringScope: String, Codable, CaseIterable, Identifiable {
1438 case allTracked
1439 case selectedOnly
1440
1441 var id: String { rawValue }
1442
1443 var title: String {
1444 switch self {
1445 case .allTracked:
1446 return "All Tracked"
1447 case .selectedOnly:
1448 return "Selected Only"
1449 }
1450 }
1451}
1452
1453enum MonitoringAlertSeverity: Int, Codable, CaseIterable, Comparable {
1454 case info
1455 case warning
1456 case critical
1457
1458 static func < (lhs: MonitoringAlertSeverity, rhs: MonitoringAlertSeverity) -> Bool {
1459 lhs.rawValue < rhs.rawValue
1460 }
1461
1462 var title: String {
1463 switch self {
1464 case .info:
1465 return "Info"
1466 case .warning:
1467 return "Warning"
1468 case .critical:
1469 return "Critical"
1470 }
1471 }
1472}
1473
1474enum MonitoringAlertFilter: String, Codable, CaseIterable, Identifiable {
1475 case criticalOnly
1476 case criticalAndWarnings
1477 case allChanges
1478
1479 var id: String { rawValue }
1480
1481 var title: String {
1482 switch self {
1483 case .criticalOnly:
1484 return "Critical Only"
1485 case .criticalAndWarnings:
1486 return "Critical + Warnings"
1487 case .allChanges:
1488 return "All Changes"
1489 }
1490 }
1491
1492 var minimumSeverity: MonitoringAlertSeverity {
1493 switch self {
1494 case .criticalOnly:
1495 return .critical
1496 case .criticalAndWarnings:
1497 return .warning
1498 case .allChanges:
1499 return .info
1500 }
1501 }
1502}
1503
1504enum MonitoringRunTrigger: String, Codable {
1505 case manual
1506 case background
1507 case cli
1508
1509 var title: String {
1510 switch self {
1511 case .manual:
1512 return "Manual"
1513 case .background:
1514 return "Background"
1515 case .cli:
1516 return "CLI"
1517 }
1518 }
1519}
1520
1521struct MonitoringSettings: Codable, Equatable {
1522 private enum CodingKeys: String, CodingKey {
1523 case config
1524 case isEnabled
1525 case frequency
1526 case scope
1527 case selectedDomainIDs
1528 case alertFilter
1529 case alertsEnabled
1530 }
1531
1532 var config: MonitoringConfig
1533 var scope: MonitoringScope
1534 var selectedDomainIDs: [UUID]
1535 var alertFilter: MonitoringAlertFilter
1536 var alertsEnabled: Bool
1537
1538 init(
1539 config: MonitoringConfig = MonitoringConfig(),
1540 scope: MonitoringScope = .allTracked,
1541 selectedDomainIDs: [UUID] = [],
1542 alertFilter: MonitoringAlertFilter = .criticalAndWarnings,
1543 alertsEnabled: Bool = false
1544 ) {
1545 self.config = config
1546 self.scope = scope
1547 self.selectedDomainIDs = selectedDomainIDs
1548 self.alertFilter = alertFilter
1549 self.alertsEnabled = alertsEnabled
1550 }
1551
1552 init(from decoder: Decoder) throws {
1553 let container = try decoder.container(keyedBy: CodingKeys.self)
1554 let legacyEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false
1555 let legacyFrequencyRaw = try container.decodeIfPresent(String.self, forKey: .frequency)
1556
1557 if let decodedConfig = try container.decodeIfPresent(MonitoringConfig.self, forKey: .config) {
1558 config = decodedConfig
1559 } else {
1560 let mappedInterval: TimeInterval
1561 switch legacyFrequencyRaw {
1562 case "daily":
1563 mappedInterval = MonitoringBaseInterval.daily.interval
1564 case "twiceDaily":
1565 mappedInterval = MonitoringBaseInterval.twelveHours.interval
1566 default:
1567 mappedInterval = MonitoringBaseInterval.twelveHours.interval
1568 }
1569
1570 config = MonitoringConfig(
1571 isEnabled: legacyEnabled,
1572 baseInterval: mappedInterval,
1573 adaptiveEnabled: true,
1574 sensitivity: .medium,
1575 quietHours: nil
1576 )
1577 }
1578
1579 scope = try container.decodeIfPresent(MonitoringScope.self, forKey: .scope) ?? .allTracked
1580 selectedDomainIDs = try container.decodeIfPresent([UUID].self, forKey: .selectedDomainIDs) ?? []
1581 alertFilter = try container.decodeIfPresent(MonitoringAlertFilter.self, forKey: .alertFilter) ?? .criticalAndWarnings
1582 alertsEnabled = try container.decodeIfPresent(Bool.self, forKey: .alertsEnabled) ?? false
1583 }
1584
1585 func encode(to encoder: Encoder) throws {
1586 var container = encoder.container(keyedBy: CodingKeys.self)
1587 try container.encode(config, forKey: .config)
1588 try container.encode(scope, forKey: .scope)
1589 try container.encode(selectedDomainIDs, forKey: .selectedDomainIDs)
1590 try container.encode(alertFilter, forKey: .alertFilter)
1591 try container.encode(alertsEnabled, forKey: .alertsEnabled)
1592 }
1593
1594 var isEnabled: Bool {
1595 get { config.isEnabled }
1596 set { config.isEnabled = newValue }
1597 }
1598
1599 var baseInterval: TimeInterval {
1600 get { config.baseInterval }
1601 set { config.baseInterval = newValue }
1602 }
1603
1604 var adaptiveEnabled: Bool {
1605 get { config.adaptiveEnabled }
1606 set { config.adaptiveEnabled = newValue }
1607 }
1608
1609 var sensitivity: MonitoringSensitivity {
1610 get { config.sensitivity }
1611 set { config.sensitivity = newValue }
1612 }
1613
1614 var quietHours: QuietHours? {
1615 get { config.quietHours }
1616 set { config.quietHours = newValue }
1617 }
1618}
1619
1620struct MonitoringDomainResult: Codable, Identifiable, Equatable {
1621 let id: UUID
1622 let domain: String
1623 let historyEntryID: UUID?
1624 let checkedAt: Date
1625 let didChange: Bool
1626 let summaryMessage: String
1627 let alertSeverity: MonitoringAlertSeverity?
1628 let certificateWarningLevel: CertificateWarningLevel
1629 let resultSource: LookupResultSource
1630 let errorMessage: String?
1631 /// Non-nil when the lookup failed and the previous snapshot was reused, so
1632 /// this run compared the old data against itself and cannot claim the
1633 /// domain is unchanged. Optional so already-persisted logs still decode.
1634 let unreachableReason: String?
1635
1636 init(
1637 id: UUID = UUID(),
1638 domain: String,
1639 historyEntryID: UUID?,
1640 checkedAt: Date,
1641 didChange: Bool,
1642 summaryMessage: String,
1643 alertSeverity: MonitoringAlertSeverity?,
1644 certificateWarningLevel: CertificateWarningLevel,
1645 resultSource: LookupResultSource,
1646 errorMessage: String? = nil,
1647 unreachableReason: String? = nil
1648 ) {
1649 self.id = id
1650 self.domain = domain
1651 self.historyEntryID = historyEntryID
1652 self.checkedAt = checkedAt
1653 self.didChange = didChange
1654 self.summaryMessage = summaryMessage
1655 self.alertSeverity = alertSeverity
1656 self.certificateWarningLevel = certificateWarningLevel
1657 self.resultSource = resultSource
1658 self.errorMessage = errorMessage
1659 self.unreachableReason = unreachableReason
1660 }
1661}
1662
1663struct MonitoringLog: Codable, Identifiable, Equatable {
1664 let id: UUID
1665 let timestamp: Date
1666 let trigger: MonitoringRunTrigger
1667 let domainsChecked: Int
1668 let changesFound: Int
1669 let alertsTriggered: Int
1670 let checkedDomains: [MonitoringDomainResult]
1671 let errors: [String]
1672
1673 init(
1674 id: UUID = UUID(),
1675 timestamp: Date,
1676 trigger: MonitoringRunTrigger,
1677 domainsChecked: Int,
1678 changesFound: Int,
1679 alertsTriggered: Int,
1680 checkedDomains: [MonitoringDomainResult],
1681 errors: [String] = []
1682 ) {
1683 self.id = id
1684 self.timestamp = timestamp
1685 self.trigger = trigger
1686 self.domainsChecked = domainsChecked
1687 self.changesFound = changesFound
1688 self.alertsTriggered = alertsTriggered
1689 self.checkedDomains = checkedDomains
1690 self.errors = errors
1691 }
1692
1693 var summary: String {
1694 if errors.isEmpty {
1695 return "\(changesFound) changes across \(domainsChecked) domains"
1696 }
1697 return "\(changesFound) changes, \(errors.count) errors"
1698 }
1699}
1700
1701enum EventSeverity: String, Codable, CaseIterable, Comparable, Identifiable, Sendable {
1702 case info
1703 case warning
1704 case critical
1705
1706 var id: String { rawValue }
1707
1708 static func < (lhs: EventSeverity, rhs: EventSeverity) -> Bool {
1709 lhs.rank < rhs.rank
1710 }
1711
1712 var title: String {
1713 rawValue.capitalized
1714 }
1715
1716 private var rank: Int {
1717 switch self {
1718 case .info:
1719 return 0
1720 case .warning:
1721 return 1
1722 case .critical:
1723 return 2
1724 }
1725 }
1726
1727 init(monitoringSeverity: MonitoringAlertSeverity) {
1728 switch monitoringSeverity {
1729 case .info:
1730 self = .info
1731 case .warning:
1732 self = .warning
1733 case .critical:
1734 self = .critical
1735 }
1736 }
1737}
1738
1739enum MonitoringEventType: String, Codable, CaseIterable, Identifiable, Sendable {
1740 case dnsChanged
1741 case certificateUpdated
1742 case certificateExpiring
1743 case redirectChanged
1744 case headersChanged
1745 case endpointUnreachable
1746 case monitoringFailure
1747 case changeDetected
1748 case test
1749
1750 var id: String { rawValue }
1751
1752 var title: String {
1753 switch self {
1754 case .dnsChanged:
1755 return "DNS Changed"
1756 case .certificateUpdated:
1757 return "Certificate Updated"
1758 case .certificateExpiring:
1759 return "Certificate Expiring"
1760 case .redirectChanged:
1761 return "Redirect Changed"
1762 case .headersChanged:
1763 return "Headers Changed"
1764 case .endpointUnreachable:
1765 return "Endpoint Unreachable"
1766 case .monitoringFailure:
1767 return "Monitoring Failure"
1768 case .changeDetected:
1769 return "Change Detected"
1770 case .test:
1771 return "Test Event"
1772 }
1773 }
1774}
1775
1776struct MonitoringEvent: Codable, Identifiable, Equatable, Sendable {
1777 var id: UUID
1778 var type: MonitoringEventType
1779 var severity: EventSeverity
1780 var domain: String
1781 var timestamp: Date
1782 var summary: String
1783 var details: [String: String]
1784
1785 init(
1786 id: UUID = UUID(),
1787 type: MonitoringEventType,
1788 severity: EventSeverity,
1789 domain: String,
1790 timestamp: Date = Date(),
1791 summary: String,
1792 details: [String: String] = [:]
1793 ) {
1794 self.id = id
1795 self.type = type
1796 self.severity = severity
1797 self.domain = domain
1798 self.timestamp = timestamp
1799 self.summary = summary
1800 self.details = details
1801 }
1802}
1803
1804enum IntegrationType: String, Codable, CaseIterable, Identifiable, Sendable {
1805 case webhook
1806 case slack
1807 case email
1808
1809 var id: String { rawValue }
1810
1811 var title: String {
1812 rawValue.capitalized
1813 }
1814}
1815
1816enum SMTPSecurityMode: String, Codable, CaseIterable, Identifiable, Sendable {
1817 case plain
1818 case directTLS
1819
1820 var id: String { rawValue }
1821
1822 var title: String {
1823 switch self {
1824 case .plain:
1825 return "Plain"
1826 case .directTLS:
1827 return "Direct TLS"
1828 }
1829 }
1830}
1831
1832struct WebhookIntegrationConfiguration: Codable, Equatable, Sendable {
1833 var endpointDisplayHost: String
1834 var timeoutSeconds: Double
1835 var additionalHeaders: [String: String]
1836 var credentialReference: String?
1837
1838 init(
1839 endpointDisplayHost: String = "",
1840 timeoutSeconds: Double = 15,
1841 additionalHeaders: [String: String] = [:],
1842 credentialReference: String? = nil
1843 ) {
1844 self.endpointDisplayHost = endpointDisplayHost
1845 self.timeoutSeconds = timeoutSeconds
1846 self.additionalHeaders = additionalHeaders
1847 self.credentialReference = credentialReference
1848 }
1849}
1850
1851struct SlackIntegrationConfiguration: Codable, Equatable, Sendable {
1852 var destinationLabel: String
1853 var credentialReference: String?
1854
1855 init(
1856 destinationLabel: String = "Slack",
1857 credentialReference: String? = nil
1858 ) {
1859 self.destinationLabel = destinationLabel
1860 self.credentialReference = credentialReference
1861 }
1862}
1863
1864struct EmailIntegrationConfiguration: Codable, Equatable, Sendable {
1865 var smtpHost: String
1866 var port: Int
1867 var username: String
1868 var senderAddress: String
1869 var recipientAddresses: [String]
1870 var securityMode: SMTPSecurityMode
1871 var credentialReference: String?
1872
1873 init(
1874 smtpHost: String = "",
1875 port: Int = 465,
1876 username: String = "",
1877 senderAddress: String = "",
1878 recipientAddresses: [String] = [],
1879 securityMode: SMTPSecurityMode = .directTLS,
1880 credentialReference: String? = nil
1881 ) {
1882 self.smtpHost = smtpHost
1883 self.port = port
1884 self.username = username
1885 self.senderAddress = senderAddress
1886 self.recipientAddresses = recipientAddresses
1887 self.securityMode = securityMode
1888 self.credentialReference = credentialReference
1889 }
1890}
1891
1892enum IntegrationConfiguration: Codable, Equatable, Sendable {
1893 case webhook(WebhookIntegrationConfiguration)
1894 case slack(SlackIntegrationConfiguration)
1895 case email(EmailIntegrationConfiguration)
1896
1897 private enum CodingKeys: String, CodingKey {
1898 case type
1899 case webhook
1900 case slack
1901 case email
1902 }
1903
1904 init(from decoder: Decoder) throws {
1905 let container = try decoder.container(keyedBy: CodingKeys.self)
1906 let type = try container.decode(IntegrationType.self, forKey: .type)
1907 switch type {
1908 case .webhook:
1909 self = .webhook(try container.decode(WebhookIntegrationConfiguration.self, forKey: .webhook))
1910 case .slack:
1911 self = .slack(try container.decode(SlackIntegrationConfiguration.self, forKey: .slack))
1912 case .email:
1913 self = .email(try container.decode(EmailIntegrationConfiguration.self, forKey: .email))
1914 }
1915 }
1916
1917 func encode(to encoder: Encoder) throws {
1918 var container = encoder.container(keyedBy: CodingKeys.self)
1919 switch self {
1920 case .webhook(let configuration):
1921 try container.encode(IntegrationType.webhook, forKey: .type)
1922 try container.encode(configuration, forKey: .webhook)
1923 case .slack(let configuration):
1924 try container.encode(IntegrationType.slack, forKey: .type)
1925 try container.encode(configuration, forKey: .slack)
1926 case .email(let configuration):
1927 try container.encode(IntegrationType.email, forKey: .type)
1928 try container.encode(configuration, forKey: .email)
1929 }
1930 }
1931
1932 var type: IntegrationType {
1933 switch self {
1934 case .webhook:
1935 return .webhook
1936 case .slack:
1937 return .slack
1938 case .email:
1939 return .email
1940 }
1941 }
1942}
1943
1944struct IntegrationFilterSet: Codable, Equatable, Sendable {
1945 var minimumSeverity: EventSeverity
1946 var eventTypes: Set<MonitoringEventType>
1947 var domains: [String]
1948
1949 init(
1950 minimumSeverity: EventSeverity = .warning,
1951 eventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test }),
1952 domains: [String] = []
1953 ) {
1954 self.minimumSeverity = minimumSeverity
1955 self.eventTypes = eventTypes
1956 self.domains = domains
1957 }
1958
1959 func matches(_ event: MonitoringEvent) -> Bool {
1960 guard event.severity >= minimumSeverity else {
1961 return false
1962 }
1963 guard eventTypes.isEmpty || eventTypes.contains(event.type) else {
1964 return false
1965 }
1966 guard domains.isEmpty || domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) else {
1967 return false
1968 }
1969 return true
1970 }
1971}
1972
1973struct IntegrationTarget: Codable, Identifiable, Equatable, Sendable {
1974 var id: UUID
1975 var type: IntegrationType
1976 var name: String
1977 var isEnabled: Bool
1978 var configuration: IntegrationConfiguration
1979 var filters: IntegrationFilterSet
1980
1981 init(
1982 id: UUID = UUID(),
1983 type: IntegrationType,
1984 name: String,
1985 isEnabled: Bool = true,
1986 configuration: IntegrationConfiguration,
1987 filters: IntegrationFilterSet = IntegrationFilterSet()
1988 ) {
1989 self.id = id
1990 self.type = type
1991 self.name = name
1992 self.isEnabled = isEnabled
1993 self.configuration = configuration
1994 self.filters = filters
1995 }
1996}
1997
1998enum DeliveryStatus: String, Codable, CaseIterable, Identifiable, Sendable {
1999 case pending
2000 case retrying
2001 case delivered
2002 case failed
2003 case expired
2004 case skipped
2005
2006 var id: String { rawValue }
2007
2008 var title: String {
2009 rawValue.capitalized
2010 }
2011}
2012
2013struct DeliveryRecord: Codable, Identifiable, Equatable, Sendable {
2014 var id: UUID
2015 var integrationID: UUID
2016 var eventID: UUID
2017 var timestamp: Date
2018 var status: DeliveryStatus
2019 var destination: String
2020 var summary: String
2021 var failureReason: String?
2022 var attemptCount: Int
2023
2024 init(
2025 id: UUID = UUID(),
2026 integrationID: UUID,
2027 eventID: UUID,
2028 timestamp: Date = Date(),
2029 status: DeliveryStatus,
2030 destination: String,
2031 summary: String,
2032 failureReason: String? = nil,
2033 attemptCount: Int = 0
2034 ) {
2035 self.id = id
2036 self.integrationID = integrationID
2037 self.eventID = eventID
2038 self.timestamp = timestamp
2039 self.status = status
2040 self.destination = destination
2041 self.summary = summary
2042 self.failureReason = failureReason
2043 self.attemptCount = attemptCount
2044 }
2045}
2046
2047struct QueuedDelivery: Codable, Identifiable, Equatable, Sendable {
2048 var id: UUID
2049 var integrationID: UUID
2050 var event: MonitoringEvent
2051 var createdAt: Date
2052 var attemptCount: Int
2053 var nextAttemptAt: Date
2054 var lastError: String?
2055 var expiresAt: Date
2056
2057 init(
2058 id: UUID = UUID(),
2059 integrationID: UUID,
2060 event: MonitoringEvent,
2061 createdAt: Date = Date(),
2062 attemptCount: Int = 0,
2063 nextAttemptAt: Date = Date(),
2064 lastError: String? = nil,
2065 expiresAt: Date = Date().addingTimeInterval(3 * 24 * 60 * 60)
2066 ) {
2067 self.id = id
2068 self.integrationID = integrationID
2069 self.event = event
2070 self.createdAt = createdAt
2071 self.attemptCount = attemptCount
2072 self.nextAttemptAt = nextAttemptAt
2073 self.lastError = lastError
2074 self.expiresAt = expiresAt
2075 }
2076}
2077
2078// MARK: - DNS Models
2079
2080enum DNSRecordType: String, CaseIterable, Codable {
2081 case A
2082 case AAAA
2083 case MX
2084 case NS
2085 case TXT
2086 case CNAME
2087 case SOA
2088 case SRV
2089 case CAA
2090 case DS
2091 case PTR
2092
2093 var queryType: Int {
2094 switch self {
2095 case .A: return 1
2096 case .AAAA: return 28
2097 case .MX: return 15
2098 case .NS: return 2
2099 case .TXT: return 16
2100 case .CNAME: return 5
2101 case .SOA: return 6
2102 case .SRV: return 33
2103 case .CAA: return 257
2104 case .DS: return 43
2105 case .PTR: return 12
2106 }
2107 }
2108
2109 var usesRawDataValue: Bool {
2110 switch self {
2111 case .TXT, .SOA, .DS:
2112 return true
2113 default:
2114 return false
2115 }
2116 }
2117}
2118
2119struct DNSRecord: Identifiable, Codable {
2120 var id = UUID()
2121 let value: String
2122 let ttl: Int
2123}
2124
2125struct DNSSection: Identifiable, Codable {
2126 var id = UUID()
2127 let recordType: DNSRecordType
2128 var records: [DNSRecord]
2129 var wildcardRecords: [DNSRecord] = []
2130 var dnssecSigned: Bool?
2131 var error: String?
2132}
2133
2134// MARK: - SSL Models
2135
2136struct SSLCertificateInfo: Codable {
2137 struct CertChainEntry: Codable {
2138 let subject: String
2139 let issuer: String
2140 }
2141
2142 let commonName: String
2143 let subjectAltNames: [String]
2144 let issuer: String
2145 let validFrom: Date
2146 let validUntil: Date
2147 let daysUntilExpiry: Int
2148 let chainDepth: Int
2149 let tlsVersion: String?
2150 let cipherSuite: String?
2151 let chain: [CertChainEntry]
2152
2153 init(
2154 commonName: String,
2155 subjectAltNames: [String],
2156 issuer: String,
2157 validFrom: Date,
2158 validUntil: Date,
2159 daysUntilExpiry: Int,
2160 chainDepth: Int,
2161 tlsVersion: String? = nil,
2162 cipherSuite: String? = nil,
2163 chain: [CertChainEntry] = []
2164 ) {
2165 self.commonName = commonName
2166 self.subjectAltNames = subjectAltNames
2167 self.issuer = issuer
2168 self.validFrom = validFrom
2169 self.validUntil = validUntil
2170 self.daysUntilExpiry = daysUntilExpiry
2171 self.chainDepth = chainDepth
2172 self.tlsVersion = tlsVersion
2173 self.cipherSuite = cipherSuite
2174 self.chain = chain
2175 }
2176
2177 init(from decoder: Decoder) throws {
2178 let container = try decoder.container(keyedBy: CodingKeys.self)
2179 commonName = try container.decode(String.self, forKey: .commonName)
2180 subjectAltNames = try container.decode([String].self, forKey: .subjectAltNames)
2181 issuer = try container.decode(String.self, forKey: .issuer)
2182 validFrom = try container.decode(Date.self, forKey: .validFrom)
2183 validUntil = try container.decode(Date.self, forKey: .validUntil)
2184 daysUntilExpiry = try container.decode(Int.self, forKey: .daysUntilExpiry)
2185 chainDepth = try container.decode(Int.self, forKey: .chainDepth)
2186 tlsVersion = try container.decodeIfPresent(String.self, forKey: .tlsVersion)
2187 cipherSuite = try container.decodeIfPresent(String.self, forKey: .cipherSuite)
2188 chain = try container.decodeIfPresent([CertChainEntry].self, forKey: .chain) ?? []
2189 }
2190}
2191
2192// MARK: - HTTP Headers Models
2193
2194struct HTTPHeader: Identifiable, Codable {
2195 var id = UUID()
2196 let name: String
2197 let value: String
2198
2199 static let securityHeaders: Set<String> = [
2200 "strict-transport-security",
2201 "x-frame-options",
2202 "x-content-type-options",
2203 "content-security-policy",
2204 "referrer-policy"
2205 ]
2206
2207 var isSecurityHeader: Bool {
2208 Self.securityHeaders.contains(name.lowercased())
2209 }
2210}
2211
2212// MARK: - Reachability Models
2213
2214struct PortReachability: Identifiable, Codable {
2215 var id = UUID()
2216 let port: UInt16
2217 let reachable: Bool
2218 let latencyMs: Int?
2219}
2220
2221// MARK: - IP Geolocation Models
2222
2223struct IPGeolocation: Codable {
2224 let ip: String
2225 let city: String?
2226 let region: String?
2227 let countryName: String?
2228 let org: String?
2229 let latitude: Double?
2230 let longitude: Double?
2231
2232 enum CodingKeys: String, CodingKey {
2233 case ip, city, region
2234 case countryName = "country_name"
2235 case org, latitude, longitude
2236 }
2237}
2238
2239// MARK: - Ownership Models
2240
2241struct DomainOwnership: Codable, Equatable {
2242 let registrar: String?
2243 let registrant: String?
2244 let createdDate: Date?
2245 let expirationDate: Date?
2246 let status: [String]
2247 let nameservers: [String]
2248 let abuseEmail: String?
2249
2250 init(
2251 registrar: String? = nil,
2252 registrant: String? = nil,
2253 createdDate: Date? = nil,
2254 expirationDate: Date? = nil,
2255 status: [String] = [],
2256 nameservers: [String] = [],
2257 abuseEmail: String? = nil
2258 ) {
2259 self.registrar = registrar
2260 self.registrant = registrant
2261 self.createdDate = createdDate
2262 self.expirationDate = expirationDate
2263 self.status = status
2264 self.nameservers = nameservers
2265 self.abuseEmail = abuseEmail
2266 }
2267
2268 init(from decoder: Decoder) throws {
2269 let container = try decoder.container(keyedBy: CodingKeys.self)
2270 registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
2271 registrant = try container.decodeIfPresent(String.self, forKey: .registrant)
2272 createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
2273 expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
2274 status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
2275 nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
2276 abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail)
2277 }
2278}
2279
2280struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable {
2281 var id: String { hostname }
2282 let hostname: String
2283 let source: String?
2284 let isExtended: Bool
2285
2286 nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) {
2287 self.hostname = hostname
2288 self.source = source
2289 self.isExtended = isExtended
2290 }
2291}
2292
2293// MARK: - Email Security Models
2294
2295struct EmailSecurityResult: Codable {
2296 let spf: EmailSecurityRecord
2297 let dmarc: EmailSecurityRecord
2298 let dkim: EmailSecurityRecord
2299 let bimi: EmailSecurityRecord
2300 let mtaSts: MTASTSResult?
2301
2302 init(
2303 spf: EmailSecurityRecord,
2304 dmarc: EmailSecurityRecord,
2305 dkim: EmailSecurityRecord,
2306 bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil),
2307 mtaSts: MTASTSResult? = nil
2308 ) {
2309 self.spf = spf
2310 self.dmarc = dmarc
2311 self.dkim = dkim
2312 self.bimi = bimi
2313 self.mtaSts = mtaSts
2314 }
2315
2316 init(from decoder: Decoder) throws {
2317 let container = try decoder.container(keyedBy: CodingKeys.self)
2318 spf = try container.decode(EmailSecurityRecord.self, forKey: .spf)
2319 dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc)
2320 dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim)
2321 bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi)
2322 ?? EmailSecurityRecord(found: false, value: nil)
2323 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts)
2324 }
2325}
2326
2327struct EmailSecurityRecord: Codable {
2328 let found: Bool
2329 let value: String?
2330 let matchedSelector: String?
2331
2332 init(found: Bool, value: String?, matchedSelector: String? = nil) {
2333 self.found = found
2334 self.value = value
2335 self.matchedSelector = matchedSelector
2336 }
2337
2338 init(from decoder: Decoder) throws {
2339 let container = try decoder.container(keyedBy: CodingKeys.self)
2340 found = try container.decode(Bool.self, forKey: .found)
2341 value = try container.decodeIfPresent(String.self, forKey: .value)
2342 matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector)
2343 }
2344}
2345
2346struct MTASTSResult: Codable {
2347 let txtFound: Bool
2348 let policyMode: String?
2349}
2350
2351// MARK: - Redirect Chain Models
2352
2353struct RedirectHop: Identifiable, Codable {
2354 var id = UUID()
2355 let stepNumber: Int
2356 let statusCode: Int
2357 let url: String
2358 let isFinal: Bool
2359}
2360
2361// MARK: - Port Scan Models
2362
2363enum PortScanKind: String, Codable {
2364 case standard
2365 case custom
2366}
2367
2368struct PortScanResult: Identifiable, Codable {
2369 var id = UUID()
2370 let port: UInt16
2371 let service: String
2372 let open: Bool
2373 var banner: String?
2374 let kind: PortScanKind
2375 let durationMs: Int?
2376
2377 nonisolated init(
2378 port: UInt16,
2379 service: String,
2380 open: Bool,
2381 banner: String? = nil,
2382 kind: PortScanKind = .standard,
2383 durationMs: Int? = nil
2384 ) {
2385 self.port = port
2386 self.service = service
2387 self.open = open
2388 self.banner = banner
2389 self.kind = kind
2390 self.durationMs = durationMs
2391 }
2392
2393 init(from decoder: Decoder) throws {
2394 let container = try decoder.container(keyedBy: CodingKeys.self)
2395 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2396 port = try container.decode(UInt16.self, forKey: .port)
2397 service = try container.decode(String.self, forKey: .service)
2398 open = try container.decode(Bool.self, forKey: .open)
2399 banner = try container.decodeIfPresent(String.self, forKey: .banner)
2400 kind = try container.decodeIfPresent(PortScanKind.self, forKey: .kind) ?? .standard
2401 durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs)
2402 }
2403}
2404
2405// MARK: - History Models
2406
2407struct HistoryEntry: Identifiable, Codable {
2408 var id = UUID()
2409 let domain: String
2410 let timestamp: Date
2411 var trackedDomainID: UUID?
2412 var note: String?
2413 let dnsSections: [DNSSection]
2414 let sslInfo: SSLCertificateInfo?
2415 let httpHeaders: [HTTPHeader]
2416 let reachabilityResults: [PortReachability]
2417 let ipGeolocation: IPGeolocation?
2418 var emailSecurity: EmailSecurityResult?
2419 var mtaSts: MTASTSResult?
2420 var ownership: DomainOwnership?
2421 var ownershipHistory: [DomainOwnershipHistoryEvent]
2422 var inferredProvider: InferredProviderFingerprint?
2423 var priorProviders: [String]
2424 var domainClassification: DomainClassificationSummary?
2425 var ownershipTransitions: [OwnershipTransitionEvent]
2426 var hostingTransitions: [HostingTransitionEvent]
2427 var subdomainHistory: [SubdomainHistoryEntry]
2428 var riskSignals: [IntelligenceRiskSignal]
2429 var intelligenceTimeline: [IntelligenceTimelineEvent]
2430 var ptrRecord: String?
2431 var redirectChain: [RedirectHop]
2432 var subdomains: [DiscoveredSubdomain]
2433 var extendedSubdomains: [DiscoveredSubdomain]
2434 var dnsHistory: [DNSHistoryEvent]
2435 var domainPricing: DomainPricingInsight?
2436 var reputation: DomainReputationResult?
2437 var portScanResults: [PortScanResult]
2438 var hstsPreloaded: Bool?
2439 var availabilityResult: DomainAvailabilityResult?
2440 var suggestions: [DomainSuggestionResult]
2441 var appVersion: String
2442 var resultSource: LookupResultSource
2443 var dataSources: [String]
2444 var provenanceBySection: [LookupSectionKind: SectionProvenance]
2445 var availabilityConfidence: ConfidenceLevel?
2446 var ownershipConfidence: ConfidenceLevel?
2447 var subdomainConfidence: ConfidenceLevel?
2448 var emailSecurityConfidence: ConfidenceLevel?
2449 var geolocationConfidence: ConfidenceLevel?
2450 var errorDetails: [LookupSectionKind: InspectionFailure]
2451 var isPartialSnapshot: Bool
2452 var validationIssues: [String]
2453 var resolverDisplayName: String
2454 var resolverURLString: String
2455 var totalLookupDurationMs: Int?
2456 var primaryIP: String?
2457 var finalRedirectURL: String?
2458 var tlsStatusSummary: String?
2459 var emailSecuritySummary: String?
2460 var httpGradeSummary: String?
2461 var changeSummary: DomainChangeSummary?
2462 var snapshotIndex: Int?
2463 var previousSnapshotID: UUID?
2464 var changeCount: Int
2465 var severitySummary: ChangeSeverity?
2466 var sslError: String?
2467 var httpHeadersError: String?
2468 var reachabilityError: String?
2469 var ipGeolocationError: String?
2470 var emailSecurityError: String?
2471 var ownershipError: String?
2472 var ownershipHistoryError: String?
2473 var ptrError: String?
2474 var redirectChainError: String?
2475 var subdomainsError: String?
2476 var extendedSubdomainsError: String?
2477 var dnsHistoryError: String?
2478 var domainPricingError: String?
2479 var reputationError: String?
2480 var portScanError: String?
2481
2482 // A full inspection produces 73 stored properties, and passing them as one
2483 // flat argument list made a 72-parameter initializer no call site could read
2484 // (SonarCloud swift:S107). They are grouped below by what they describe.
2485 //
2486 // The stored properties stay flat, deliberately. This type is persisted to
2487 // UserDefaults and read back out of backup files, and both decode paths drop
2488 // entries that will not parse rather than raising — so nesting a value would
2489 // change the encoded shape and silently discard history written by an older
2490 // build. These groups exist at the call boundary only; the JSON is unchanged,
2491 // which HistoryEntryCodableTests pins.
2492
2493 /// What was inspected, and when.
2494 struct Identity {
2495 var domain: String
2496 var timestamp: Date
2497 var trackedDomainID: UUID? = nil
2498 var note: String? = nil
2499 }
2500
2501 /// The live probe results for this snapshot.
2502 struct Inspection {
2503 var dnsSections: [DNSSection]
2504 var sslInfo: SSLCertificateInfo?
2505 var httpHeaders: [HTTPHeader]
2506 var reachabilityResults: [PortReachability]
2507 var ipGeolocation: IPGeolocation?
2508 var emailSecurity: EmailSecurityResult? = nil
2509 var mtaSts: MTASTSResult? = nil
2510 var ptrRecord: String? = nil
2511 var redirectChain: [RedirectHop] = []
2512 var subdomains: [DiscoveredSubdomain] = []
2513 var extendedSubdomains: [DiscoveredSubdomain] = []
2514 var dnsHistory: [DNSHistoryEvent] = []
2515 var portScanResults: [PortScanResult] = []
2516 var hstsPreloaded: Bool? = nil
2517 var availabilityResult: DomainAvailabilityResult? = nil
2518 var suggestions: [DomainSuggestionResult] = []
2519
2520 }
2521
2522 /// Who owns the domain and where it is hosted.
2523 struct Registration {
2524 var ownership: DomainOwnership? = nil
2525 var ownershipHistory: [DomainOwnershipHistoryEvent] = []
2526 var inferredProvider: InferredProviderFingerprint? = nil
2527 var priorProviders: [String] = []
2528 var domainClassification: DomainClassificationSummary? = nil
2529 var ownershipTransitions: [OwnershipTransitionEvent] = []
2530 var hostingTransitions: [HostingTransitionEvent] = []
2531 var domainPricing: DomainPricingInsight? = nil
2532 }
2533
2534 /// Derived signals rather than direct observations.
2535 struct Intelligence {
2536 var subdomainHistory: [SubdomainHistoryEntry] = []
2537 var riskSignals: [IntelligenceRiskSignal] = []
2538 var intelligenceTimeline: [IntelligenceTimelineEvent] = []
2539 var reputation: DomainReputationResult? = nil
2540 }
2541
2542 /// Where the data came from, and how much to trust it.
2543 struct Provenance {
2544 var appVersion: String = "2.7.0"
2545 var resultSource: LookupResultSource = .snapshot
2546 var dataSources: [String] = []
2547 var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:]
2548 var availabilityConfidence: ConfidenceLevel? = nil
2549 var ownershipConfidence: ConfidenceLevel? = nil
2550 var subdomainConfidence: ConfidenceLevel? = nil
2551 var emailSecurityConfidence: ConfidenceLevel? = nil
2552 var geolocationConfidence: ConfidenceLevel? = nil
2553 var isPartialSnapshot: Bool = false
2554 var validationIssues: [String] = []
2555 var resolverDisplayName: String
2556 var resolverURLString: String
2557 var totalLookupDurationMs: Int? = nil
2558
2559 }
2560
2561 /// Precomputed display values and change tracking.
2562 struct Summary {
2563 var primaryIP: String? = nil
2564 var finalRedirectURL: String? = nil
2565 var tlsStatusSummary: String? = nil
2566 var emailSecuritySummary: String? = nil
2567 var httpGradeSummary: String? = nil
2568 var changeSummary: DomainChangeSummary? = nil
2569 var snapshotIndex: Int? = nil
2570 var previousSnapshotID: UUID? = nil
2571 var changeCount: Int = 0
2572 var severitySummary: ChangeSeverity? = nil
2573 }
2574
2575 /// Per-section failures: a snapshot records what it could not collect.
2576 struct Failures {
2577 var errorDetails: [LookupSectionKind: InspectionFailure] = [:]
2578 var sslError: String? = nil
2579 var httpHeadersError: String? = nil
2580 var reachabilityError: String? = nil
2581 var ipGeolocationError: String? = nil
2582 var emailSecurityError: String? = nil
2583 var ownershipError: String? = nil
2584 var ownershipHistoryError: String? = nil
2585 var ptrError: String? = nil
2586 var redirectChainError: String? = nil
2587 var subdomainsError: String? = nil
2588 var extendedSubdomainsError: String? = nil
2589 var dnsHistoryError: String? = nil
2590 var domainPricingError: String? = nil
2591 var reputationError: String? = nil
2592 var portScanError: String? = nil
2593
2594 }
2595
2596 init(identity: Identity,
2597 inspection: Inspection,
2598 registration: Registration = Registration(),
2599 intelligence: Intelligence = Intelligence(),
2600 provenance: Provenance,
2601 summary: Summary = Summary(),
2602 failures: Failures = Failures()) {
2603 self.domain = identity.domain
2604 self.timestamp = identity.timestamp
2605 self.trackedDomainID = identity.trackedDomainID
2606 self.note = identity.note
2607 self.dnsSections = inspection.dnsSections
2608 self.sslInfo = inspection.sslInfo
2609 self.httpHeaders = inspection.httpHeaders
2610 self.reachabilityResults = inspection.reachabilityResults
2611 self.ipGeolocation = inspection.ipGeolocation
2612 self.emailSecurity = inspection.emailSecurity
2613 self.mtaSts = inspection.mtaSts ?? inspection.emailSecurity?.mtaSts
2614 self.ptrRecord = inspection.ptrRecord
2615 self.redirectChain = inspection.redirectChain
2616 self.subdomains = inspection.subdomains
2617 self.extendedSubdomains = inspection.extendedSubdomains
2618 self.dnsHistory = inspection.dnsHistory
2619 self.portScanResults = inspection.portScanResults
2620 self.hstsPreloaded = inspection.hstsPreloaded
2621 self.availabilityResult = inspection.availabilityResult
2622 self.suggestions = inspection.suggestions
2623 self.ownership = registration.ownership
2624 self.ownershipHistory = registration.ownershipHistory
2625 self.inferredProvider = registration.inferredProvider
2626 self.priorProviders = registration.priorProviders
2627 self.domainClassification = registration.domainClassification
2628 self.ownershipTransitions = registration.ownershipTransitions
2629 self.hostingTransitions = registration.hostingTransitions
2630 self.domainPricing = registration.domainPricing
2631 self.subdomainHistory = intelligence.subdomainHistory
2632 self.riskSignals = intelligence.riskSignals
2633 self.intelligenceTimeline = intelligence.intelligenceTimeline
2634 self.reputation = intelligence.reputation
2635 self.appVersion = provenance.appVersion
2636 self.resultSource = provenance.resultSource
2637 self.dataSources = provenance.dataSources
2638 self.provenanceBySection = provenance.provenanceBySection
2639 self.availabilityConfidence = provenance.availabilityConfidence
2640 self.ownershipConfidence = provenance.ownershipConfidence
2641 self.subdomainConfidence = provenance.subdomainConfidence
2642 self.emailSecurityConfidence = provenance.emailSecurityConfidence
2643 self.geolocationConfidence = provenance.geolocationConfidence
2644 self.isPartialSnapshot = provenance.isPartialSnapshot
2645 self.validationIssues = provenance.validationIssues
2646 self.resolverDisplayName = provenance.resolverDisplayName
2647 self.resolverURLString = provenance.resolverURLString
2648 self.totalLookupDurationMs = provenance.totalLookupDurationMs
2649 self.primaryIP = summary.primaryIP
2650 self.finalRedirectURL = summary.finalRedirectURL
2651 self.tlsStatusSummary = summary.tlsStatusSummary
2652 self.emailSecuritySummary = summary.emailSecuritySummary
2653 self.httpGradeSummary = summary.httpGradeSummary
2654 self.changeSummary = summary.changeSummary
2655 self.snapshotIndex = summary.snapshotIndex
2656 self.previousSnapshotID = summary.previousSnapshotID
2657 self.changeCount = summary.changeCount
2658 self.severitySummary = summary.severitySummary
2659 self.errorDetails = failures.errorDetails
2660 self.sslError = failures.sslError
2661 self.httpHeadersError = failures.httpHeadersError
2662 self.reachabilityError = failures.reachabilityError
2663 self.ipGeolocationError = failures.ipGeolocationError
2664 self.emailSecurityError = failures.emailSecurityError
2665 self.ownershipError = failures.ownershipError
2666 self.ownershipHistoryError = failures.ownershipHistoryError
2667 self.ptrError = failures.ptrError
2668 self.redirectChainError = failures.redirectChainError
2669 self.subdomainsError = failures.subdomainsError
2670 self.extendedSubdomainsError = failures.extendedSubdomainsError
2671 self.dnsHistoryError = failures.dnsHistoryError
2672 self.domainPricingError = failures.domainPricingError
2673 self.reputationError = failures.reputationError
2674 self.portScanError = failures.portScanError
2675 }
2676
2677 init(from decoder: Decoder) throws {
2678 let container = try decoder.container(keyedBy: CodingKeys.self)
2679 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2680 domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain"
2681 timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast
2682 trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID)
2683 note = try container.decodeIfPresent(String.self, forKey: .note)
2684 dnsSections = try container.decodeIfPresent([DNSSection].self, forKey: .dnsSections) ?? []
2685 sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
2686 httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? []
2687 reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? []
2688 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
2689 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
2690 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
2691 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
2692 ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
2693 inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider)
2694 priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? []
2695 domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification)
2696 ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? []
2697 hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? []
2698 subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? []
2699 riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? []
2700 intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? []
2701 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
2702 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
2703 subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
2704 extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
2705 dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
2706 domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
2707 reputation = try container.decodeIfPresent(DomainReputationResult.self, forKey: .reputation)
2708 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
2709 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
2710 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
2711 suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? []
2712 appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "2.6.0"
2713 resultSource = try container.decodeIfPresent(LookupResultSource.self, forKey: .resultSource) ?? .snapshot
2714 dataSources = try container.decodeIfPresent([String].self, forKey: .dataSources) ?? []
2715 provenanceBySection = try container.decodeIfPresent([LookupSectionKind: SectionProvenance].self, forKey: .provenanceBySection) ?? [:]
2716 availabilityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .availabilityConfidence)
2717 ownershipConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .ownershipConfidence)
2718 subdomainConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .subdomainConfidence)
2719 emailSecurityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .emailSecurityConfidence)
2720 geolocationConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .geolocationConfidence)
2721 errorDetails = try container.decodeIfPresent([LookupSectionKind: InspectionFailure].self, forKey: .errorDetails) ?? [:]
2722 validationIssues = try container.decodeIfPresent([String].self, forKey: .validationIssues) ?? HistoryEntry.defaultValidationIssues(domain: domain, timestamp: timestamp)
2723 isPartialSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isPartialSnapshot) ?? !validationIssues.isEmpty
2724 resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
2725 resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
2726 totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
2727 primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP)
2728 finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL)
2729 tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary)
2730 emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
2731 httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
2732 changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
2733 snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex)
2734 previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID)
2735 changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount)
2736 ?? changeSummary?.changedSections.count
2737 ?? 0
2738 severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary)
2739 ?? changeSummary?.severity
2740 sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
2741 httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
2742 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
2743 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
2744 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
2745 ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
2746 ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError)
2747 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
2748 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
2749 subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
2750 extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
2751 dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
2752 domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
2753 reputationError = try container.decodeIfPresent(String.self, forKey: .reputationError)
2754 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
2755 }
2756
2757 private static func defaultValidationIssues(domain: String, timestamp: Date) -> [String] {
2758 var issues: [String] = []
2759 if domain == "unknown-domain" {
2760 issues.append("Missing domain in stored snapshot")
2761 }
2762 if timestamp == .distantPast {
2763 issues.append("Missing collection timestamp in stored snapshot")
2764 }
2765 return issues
2766 }
2767
2768 var snapshotSummary: SnapshotSummary {
2769 SnapshotSummary(
2770 id: id,
2771 domain: domain,
2772 timestamp: timestamp,
2773 trackedDomainID: trackedDomainID,
2774 snapshotIndex: snapshotIndex,
2775 previousSnapshotID: previousSnapshotID,
2776 changeCount: changeCount,
2777 severitySummary: severitySummary ?? changeSummary?.severity,
2778 changeSummaryMessage: changeSummary?.message,
2779 availability: availabilityResult?.status,
2780 primaryIP: primaryIP,
2781 tlsStatus: tlsStatusSummary,
2782 riskScore: changeSummary?.riskAssessment?.score,
2783 historyEntryID: id
2784 )
2785 }
2786}
2787
2788// MARK: - Cloudflare DNS-over-HTTPS Response
2789
2790struct CloudflareDNSResponse: Decodable {
2791 let status: Int
2792 let authenticatedData: Bool?
2793 let answer: [CloudflareDNSAnswer]?
2794
2795 enum CodingKeys: String, CodingKey {
2796 case status = "Status"
2797 case authenticatedData = "AD"
2798 case answer = "Answer"
2799 }
2800
2801 struct CloudflareDNSAnswer: Decodable {
2802 let name: String
2803 let type: Int
2804 let ttl: Int
2805 let data: String
2806
2807 enum CodingKeys: String, CodingKey {
2808 case name, type
2809 case ttl = "TTL"
2810 case data
2811 }
2812 }
2813}