krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.7.0: DomainDig/Models.swift · raw
1import Foundation
2
3enum ServiceResult<Value> {
4 case success(Value)
5 case empty(String)
6 case error(String)
7}
8
9enum ConfidenceLevel: String, Codable {
10 case high
11 case medium
12 case low
13
14 var title: String {
15 rawValue.capitalized
16 }
17}
18
19enum InspectionErrorKind: String, Codable {
20 case network
21 case timeout
22 case rateLimited
23 case parsing
24 case unsupported
25 case unavailable
26 case unknown
27
28 var title: String {
29 switch self {
30 case .network:
31 return "Network"
32 case .timeout:
33 return "Timeout"
34 case .rateLimited:
35 return "Rate limited"
36 case .parsing:
37 return "Parsing"
38 case .unsupported:
39 return "Unsupported"
40 case .unavailable:
41 return "Unavailable"
42 case .unknown:
43 return "Unknown"
44 }
45 }
46}
47
48struct InspectionFailure: Codable, Equatable {
49 let kind: InspectionErrorKind
50 let message: String
51 let details: String?
52}
53
54struct SectionProvenance: Codable, Equatable {
55 let source: String
56 let collectedAt: Date
57 let provider: String?
58 let resolver: String?
59 let resultSource: LookupResultSource
60}
61
62enum LookupResultSource: String, Codable {
63 case live
64 case cached
65 case mixed
66 case snapshot
67
68 var label: String {
69 switch self {
70 case .live:
71 return "Live"
72 case .cached:
73 return "Cached"
74 case .mixed:
75 return "Mixed"
76 case .snapshot:
77 return "Snapshot"
78 }
79 }
80}
81
82enum LookupSectionKind: String, Codable, CaseIterable {
83 case dns
84 case availability
85 case ssl
86 case hsts
87 case httpHeaders
88 case reachability
89 case ipGeolocation
90 case emailSecurity
91 case ownership
92 case ptr
93 case redirectChain
94 case subdomains
95 case portScan
96 case suggestions
97}
98
99enum DomainAvailabilityStatus: String, Codable {
100 case available
101 case registered
102 case unknown
103}
104
105struct DomainAvailabilityResult: Codable {
106 let domain: String
107 let status: DomainAvailabilityStatus
108}
109
110struct DomainSuggestionResult: Identifiable, Codable {
111 let id: UUID
112 let domain: String
113 let status: DomainAvailabilityStatus
114
115 init(id: UUID = UUID(), domain: String, status: DomainAvailabilityStatus) {
116 self.id = id
117 self.domain = domain
118 self.status = status
119 }
120}
121
122struct WatchedDomain: Codable, Identifiable {
123 let id: UUID
124 let domain: String
125 let createdAt: Date
126 var lastKnownAvailability: DomainAvailabilityStatus?
127
128 init(
129 id: UUID = UUID(),
130 domain: String,
131 createdAt: Date = Date(),
132 lastKnownAvailability: DomainAvailabilityStatus? = nil
133 ) {
134 self.id = id
135 self.domain = domain
136 self.createdAt = createdAt
137 self.lastKnownAvailability = lastKnownAvailability
138 }
139}
140
141enum ChangeSeverity: Int, Codable, CaseIterable, Comparable {
142 case low
143 case medium
144 case high
145
146 static func < (lhs: ChangeSeverity, rhs: ChangeSeverity) -> Bool {
147 lhs.rawValue < rhs.rawValue
148 }
149
150 var title: String {
151 switch self {
152 case .low:
153 return "Low"
154 case .medium:
155 return "Medium"
156 case .high:
157 return "High"
158 }
159 }
160}
161
162enum CertificateWarningLevel: String, Codable {
163 case none
164 case warning
165 case critical
166
167 var title: String {
168 switch self {
169 case .none:
170 return "Healthy"
171 case .warning:
172 return "Warning"
173 case .critical:
174 return "Critical"
175 }
176 }
177}
178
179enum DomainHealth: String, Codable, Sendable, CaseIterable {
180 case healthy
181 case warning
182 case critical
183
184 var title: String {
185 rawValue.capitalized
186 }
187}
188
189struct PortfolioSnapshot: Codable, Sendable {
190 var totalDomains: Int
191 var healthyCount: Int
192 var warningCount: Int
193 var criticalCount: Int
194 var changedLast24h: Int
195 var expiringSoonCount: Int
196 var unreachableCount: Int
197}
198
199struct DomainChangeSummary: Codable, Equatable {
200 let hasChanges: Bool
201 let changedSections: [String]
202 let message: String
203 let severity: ChangeSeverity
204 let impactClassification: ChangeImpactClassification
205 let generatedAt: Date
206 let observedFacts: [String]
207 let inferredConclusions: [String]
208 let contextNote: String?
209 let riskAssessment: DomainRiskAssessment?
210 let insights: [String]
211 let riskScoreDelta: Int?
212
213 init(
214 hasChanges: Bool,
215 changedSections: [String],
216 message: String,
217 severity: ChangeSeverity,
218 impactClassification: ChangeImpactClassification,
219 generatedAt: Date,
220 observedFacts: [String] = [],
221 inferredConclusions: [String] = [],
222 contextNote: String? = nil,
223 riskAssessment: DomainRiskAssessment? = nil,
224 insights: [String] = [],
225 riskScoreDelta: Int? = nil
226 ) {
227 self.hasChanges = hasChanges
228 self.changedSections = changedSections
229 self.message = message
230 self.severity = severity
231 self.impactClassification = impactClassification
232 self.generatedAt = generatedAt
233 self.observedFacts = observedFacts
234 self.inferredConclusions = inferredConclusions
235 self.contextNote = contextNote
236 self.riskAssessment = riskAssessment
237 self.insights = insights
238 self.riskScoreDelta = riskScoreDelta
239 }
240
241 init(from decoder: Decoder) throws {
242 let container = try decoder.container(keyedBy: CodingKeys.self)
243 hasChanges = try container.decodeIfPresent(Bool.self, forKey: .hasChanges) ?? false
244 changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? []
245 generatedAt = try container.decode(Date.self, forKey: .generatedAt)
246 severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low)
247 impactClassification = try container.decodeIfPresent(ChangeImpactClassification.self, forKey: .impactClassification)
248 ?? (severity == .high ? .critical : (hasChanges ? .warning : .informational))
249 message = try container.decodeIfPresent(String.self, forKey: .message)
250 ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • "))
251 observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? []
252 inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? []
253 contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote)
254 riskAssessment = try container.decodeIfPresent(DomainRiskAssessment.self, forKey: .riskAssessment)
255 insights = try container.decodeIfPresent([String].self, forKey: .insights) ?? []
256 riskScoreDelta = try container.decodeIfPresent(Int.self, forKey: .riskScoreDelta)
257 }
258}
259
260enum BatchLookupSource: String, Codable {
261 case manual
262 case watchlistRefresh
263 case workflow
264}
265
266enum BatchLookupStatus: String, Codable {
267 case pending
268 case running
269 case completed
270 case failed
271}
272
273struct BatchLookupResult: Identifiable, Codable, Equatable {
274 let id: UUID
275 let domain: String
276 let historyEntryID: UUID?
277 let resultSource: LookupResultSource
278 let availability: DomainAvailabilityStatus?
279 let primaryIP: String?
280 let quickStatus: String
281 let summaryMessage: String?
282 let changeSeverity: ChangeSeverity?
283 let changeClassification: ChangeImpactClassification?
284 let certificateWarningLevel: CertificateWarningLevel
285 let riskScore: Int?
286 let riskLevel: RiskLevel?
287 let timestamp: Date
288 let status: BatchLookupStatus
289 let errorMessage: String?
290
291 init(
292 id: UUID = UUID(),
293 domain: String,
294 historyEntryID: UUID?,
295 resultSource: LookupResultSource = .live,
296 availability: DomainAvailabilityStatus?,
297 primaryIP: String?,
298 quickStatus: String,
299 summaryMessage: String? = nil,
300 changeSeverity: ChangeSeverity? = nil,
301 changeClassification: ChangeImpactClassification? = nil,
302 certificateWarningLevel: CertificateWarningLevel = .none,
303 riskScore: Int? = nil,
304 riskLevel: RiskLevel? = nil,
305 timestamp: Date,
306 status: BatchLookupStatus,
307 errorMessage: String? = nil
308 ) {
309 self.id = id
310 self.domain = domain
311 self.historyEntryID = historyEntryID
312 self.resultSource = resultSource
313 self.availability = availability
314 self.primaryIP = primaryIP
315 self.quickStatus = quickStatus
316 self.summaryMessage = summaryMessage
317 self.changeSeverity = changeSeverity
318 self.changeClassification = changeClassification
319 self.certificateWarningLevel = certificateWarningLevel
320 self.riskScore = riskScore
321 self.riskLevel = riskLevel
322 self.timestamp = timestamp
323 self.status = status
324 self.errorMessage = errorMessage
325 }
326
327 var hasMeaningfulChange: Bool {
328 quickStatus == "Changed"
329 || quickStatus == "High"
330 || quickStatus == "Critical"
331 || certificateWarningLevel != .none
332 || riskLevel == .high
333 || status == .failed
334 }
335}
336
337struct BatchSweepSummary: Identifiable, Equatable {
338 let id = UUID()
339 let source: BatchLookupSource
340 let totalDomains: Int
341 let changedDomains: Int
342 let unchangedDomains: Int
343 let warningDomains: Int
344 let results: [BatchLookupResult]
345 let generatedAt: Date
346}
347
348struct DomainWorkflow: Codable, Identifiable, Equatable {
349 let id: UUID
350 var name: String
351 var domains: [String]
352 var createdAt: Date
353 var updatedAt: Date
354 var notes: String?
355 var collaboration: CollaborationMetadata?
356
357 init(
358 id: UUID = UUID(),
359 name: String,
360 domains: [String],
361 createdAt: Date = Date(),
362 updatedAt: Date = Date(),
363 notes: String? = nil,
364 collaboration: CollaborationMetadata? = nil
365 ) {
366 self.id = id
367 self.name = name
368 self.domains = domains
369 self.createdAt = createdAt
370 self.updatedAt = updatedAt
371 self.notes = notes
372 self.collaboration = collaboration
373 }
374}
375
376struct WorkflowRunSummary: Identifiable, Equatable {
377 let id = UUID()
378 let workflowID: UUID
379 let workflowName: String
380 let totalDomains: Int
381 let changedDomains: Int
382 let unchangedDomains: Int
383 let warningDomains: Int
384 let results: [BatchLookupResult]
385 let workflowInsights: [WorkflowInsight]
386 let generatedAt: Date
387}
388
389enum DataCapability: String, Codable {
390 case ownershipHistory
391 case dnsHistory
392 case extendedSubdomains
393 case domainPricing
394 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
1548 init(
1549 id: UUID = UUID(),
1550 domain: String,
1551 historyEntryID: UUID?,
1552 checkedAt: Date,
1553 didChange: Bool,
1554 summaryMessage: String,
1555 alertSeverity: MonitoringAlertSeverity?,
1556 certificateWarningLevel: CertificateWarningLevel,
1557 resultSource: LookupResultSource,
1558 errorMessage: String? = nil
1559 ) {
1560 self.id = id
1561 self.domain = domain
1562 self.historyEntryID = historyEntryID
1563 self.checkedAt = checkedAt
1564 self.didChange = didChange
1565 self.summaryMessage = summaryMessage
1566 self.alertSeverity = alertSeverity
1567 self.certificateWarningLevel = certificateWarningLevel
1568 self.resultSource = resultSource
1569 self.errorMessage = errorMessage
1570 }
1571}
1572
1573struct MonitoringLog: Codable, Identifiable, Equatable {
1574 let id: UUID
1575 let timestamp: Date
1576 let trigger: MonitoringRunTrigger
1577 let domainsChecked: Int
1578 let changesFound: Int
1579 let alertsTriggered: Int
1580 let checkedDomains: [MonitoringDomainResult]
1581 let errors: [String]
1582
1583 init(
1584 id: UUID = UUID(),
1585 timestamp: Date,
1586 trigger: MonitoringRunTrigger,
1587 domainsChecked: Int,
1588 changesFound: Int,
1589 alertsTriggered: Int,
1590 checkedDomains: [MonitoringDomainResult],
1591 errors: [String] = []
1592 ) {
1593 self.id = id
1594 self.timestamp = timestamp
1595 self.trigger = trigger
1596 self.domainsChecked = domainsChecked
1597 self.changesFound = changesFound
1598 self.alertsTriggered = alertsTriggered
1599 self.checkedDomains = checkedDomains
1600 self.errors = errors
1601 }
1602
1603 var summary: String {
1604 if errors.isEmpty {
1605 return "\(changesFound) changes across \(domainsChecked) domains"
1606 }
1607 return "\(changesFound) changes, \(errors.count) errors"
1608 }
1609}
1610
1611enum EventSeverity: String, Codable, CaseIterable, Comparable, Identifiable, Sendable {
1612 case info
1613 case warning
1614 case critical
1615
1616 var id: String { rawValue }
1617
1618 static func < (lhs: EventSeverity, rhs: EventSeverity) -> Bool {
1619 lhs.rank < rhs.rank
1620 }
1621
1622 var title: String {
1623 rawValue.capitalized
1624 }
1625
1626 private var rank: Int {
1627 switch self {
1628 case .info:
1629 return 0
1630 case .warning:
1631 return 1
1632 case .critical:
1633 return 2
1634 }
1635 }
1636
1637 init(monitoringSeverity: MonitoringAlertSeverity) {
1638 switch monitoringSeverity {
1639 case .info:
1640 self = .info
1641 case .warning:
1642 self = .warning
1643 case .critical:
1644 self = .critical
1645 }
1646 }
1647}
1648
1649enum MonitoringEventType: String, Codable, CaseIterable, Identifiable, Sendable {
1650 case dnsChanged
1651 case certificateUpdated
1652 case certificateExpiring
1653 case redirectChanged
1654 case headersChanged
1655 case endpointUnreachable
1656 case monitoringFailure
1657 case changeDetected
1658 case test
1659
1660 var id: String { rawValue }
1661
1662 var title: String {
1663 switch self {
1664 case .dnsChanged:
1665 return "DNS Changed"
1666 case .certificateUpdated:
1667 return "Certificate Updated"
1668 case .certificateExpiring:
1669 return "Certificate Expiring"
1670 case .redirectChanged:
1671 return "Redirect Changed"
1672 case .headersChanged:
1673 return "Headers Changed"
1674 case .endpointUnreachable:
1675 return "Endpoint Unreachable"
1676 case .monitoringFailure:
1677 return "Monitoring Failure"
1678 case .changeDetected:
1679 return "Change Detected"
1680 case .test:
1681 return "Test Event"
1682 }
1683 }
1684}
1685
1686struct MonitoringEvent: Codable, Identifiable, Equatable, Sendable {
1687 var id: UUID
1688 var type: MonitoringEventType
1689 var severity: EventSeverity
1690 var domain: String
1691 var timestamp: Date
1692 var summary: String
1693 var details: [String: String]
1694
1695 init(
1696 id: UUID = UUID(),
1697 type: MonitoringEventType,
1698 severity: EventSeverity,
1699 domain: String,
1700 timestamp: Date = Date(),
1701 summary: String,
1702 details: [String: String] = [:]
1703 ) {
1704 self.id = id
1705 self.type = type
1706 self.severity = severity
1707 self.domain = domain
1708 self.timestamp = timestamp
1709 self.summary = summary
1710 self.details = details
1711 }
1712}
1713
1714enum IntegrationType: String, Codable, CaseIterable, Identifiable, Sendable {
1715 case webhook
1716 case slack
1717 case email
1718
1719 var id: String { rawValue }
1720
1721 var title: String {
1722 rawValue.capitalized
1723 }
1724}
1725
1726enum SMTPSecurityMode: String, Codable, CaseIterable, Identifiable, Sendable {
1727 case plain
1728 case directTLS
1729
1730 var id: String { rawValue }
1731
1732 var title: String {
1733 switch self {
1734 case .plain:
1735 return "Plain"
1736 case .directTLS:
1737 return "Direct TLS"
1738 }
1739 }
1740}
1741
1742struct WebhookIntegrationConfiguration: Codable, Equatable, Sendable {
1743 var endpointDisplayHost: String
1744 var timeoutSeconds: Double
1745 var additionalHeaders: [String: String]
1746 var credentialReference: String?
1747
1748 init(
1749 endpointDisplayHost: String = "",
1750 timeoutSeconds: Double = 15,
1751 additionalHeaders: [String: String] = [:],
1752 credentialReference: String? = nil
1753 ) {
1754 self.endpointDisplayHost = endpointDisplayHost
1755 self.timeoutSeconds = timeoutSeconds
1756 self.additionalHeaders = additionalHeaders
1757 self.credentialReference = credentialReference
1758 }
1759}
1760
1761struct SlackIntegrationConfiguration: Codable, Equatable, Sendable {
1762 var destinationLabel: String
1763 var credentialReference: String?
1764
1765 init(
1766 destinationLabel: String = "Slack",
1767 credentialReference: String? = nil
1768 ) {
1769 self.destinationLabel = destinationLabel
1770 self.credentialReference = credentialReference
1771 }
1772}
1773
1774struct EmailIntegrationConfiguration: Codable, Equatable, Sendable {
1775 var smtpHost: String
1776 var port: Int
1777 var username: String
1778 var senderAddress: String
1779 var recipientAddresses: [String]
1780 var securityMode: SMTPSecurityMode
1781 var credentialReference: String?
1782
1783 init(
1784 smtpHost: String = "",
1785 port: Int = 465,
1786 username: String = "",
1787 senderAddress: String = "",
1788 recipientAddresses: [String] = [],
1789 securityMode: SMTPSecurityMode = .directTLS,
1790 credentialReference: String? = nil
1791 ) {
1792 self.smtpHost = smtpHost
1793 self.port = port
1794 self.username = username
1795 self.senderAddress = senderAddress
1796 self.recipientAddresses = recipientAddresses
1797 self.securityMode = securityMode
1798 self.credentialReference = credentialReference
1799 }
1800}
1801
1802enum IntegrationConfiguration: Codable, Equatable, Sendable {
1803 case webhook(WebhookIntegrationConfiguration)
1804 case slack(SlackIntegrationConfiguration)
1805 case email(EmailIntegrationConfiguration)
1806
1807 private enum CodingKeys: String, CodingKey {
1808 case type
1809 case webhook
1810 case slack
1811 case email
1812 }
1813
1814 init(from decoder: Decoder) throws {
1815 let container = try decoder.container(keyedBy: CodingKeys.self)
1816 let type = try container.decode(IntegrationType.self, forKey: .type)
1817 switch type {
1818 case .webhook:
1819 self = .webhook(try container.decode(WebhookIntegrationConfiguration.self, forKey: .webhook))
1820 case .slack:
1821 self = .slack(try container.decode(SlackIntegrationConfiguration.self, forKey: .slack))
1822 case .email:
1823 self = .email(try container.decode(EmailIntegrationConfiguration.self, forKey: .email))
1824 }
1825 }
1826
1827 func encode(to encoder: Encoder) throws {
1828 var container = encoder.container(keyedBy: CodingKeys.self)
1829 switch self {
1830 case .webhook(let configuration):
1831 try container.encode(IntegrationType.webhook, forKey: .type)
1832 try container.encode(configuration, forKey: .webhook)
1833 case .slack(let configuration):
1834 try container.encode(IntegrationType.slack, forKey: .type)
1835 try container.encode(configuration, forKey: .slack)
1836 case .email(let configuration):
1837 try container.encode(IntegrationType.email, forKey: .type)
1838 try container.encode(configuration, forKey: .email)
1839 }
1840 }
1841
1842 var type: IntegrationType {
1843 switch self {
1844 case .webhook:
1845 return .webhook
1846 case .slack:
1847 return .slack
1848 case .email:
1849 return .email
1850 }
1851 }
1852}
1853
1854struct IntegrationFilterSet: Codable, Equatable, Sendable {
1855 var minimumSeverity: EventSeverity
1856 var eventTypes: Set<MonitoringEventType>
1857 var domains: [String]
1858
1859 init(
1860 minimumSeverity: EventSeverity = .warning,
1861 eventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test }),
1862 domains: [String] = []
1863 ) {
1864 self.minimumSeverity = minimumSeverity
1865 self.eventTypes = eventTypes
1866 self.domains = domains
1867 }
1868
1869 func matches(_ event: MonitoringEvent) -> Bool {
1870 guard event.severity >= minimumSeverity else {
1871 return false
1872 }
1873 guard eventTypes.isEmpty || eventTypes.contains(event.type) else {
1874 return false
1875 }
1876 guard domains.isEmpty || domains.map({ $0.lowercased() }).contains(event.domain.lowercased()) else {
1877 return false
1878 }
1879 return true
1880 }
1881}
1882
1883struct IntegrationTarget: Codable, Identifiable, Equatable, Sendable {
1884 var id: UUID
1885 var type: IntegrationType
1886 var name: String
1887 var isEnabled: Bool
1888 var configuration: IntegrationConfiguration
1889 var filters: IntegrationFilterSet
1890
1891 init(
1892 id: UUID = UUID(),
1893 type: IntegrationType,
1894 name: String,
1895 isEnabled: Bool = true,
1896 configuration: IntegrationConfiguration,
1897 filters: IntegrationFilterSet = IntegrationFilterSet()
1898 ) {
1899 self.id = id
1900 self.type = type
1901 self.name = name
1902 self.isEnabled = isEnabled
1903 self.configuration = configuration
1904 self.filters = filters
1905 }
1906}
1907
1908enum DeliveryStatus: String, Codable, CaseIterable, Identifiable, Sendable {
1909 case pending
1910 case retrying
1911 case delivered
1912 case failed
1913 case expired
1914 case skipped
1915
1916 var id: String { rawValue }
1917
1918 var title: String {
1919 rawValue.capitalized
1920 }
1921}
1922
1923struct DeliveryRecord: Codable, Identifiable, Equatable, Sendable {
1924 var id: UUID
1925 var integrationID: UUID
1926 var eventID: UUID
1927 var timestamp: Date
1928 var status: DeliveryStatus
1929 var destination: String
1930 var summary: String
1931 var failureReason: String?
1932 var attemptCount: Int
1933
1934 init(
1935 id: UUID = UUID(),
1936 integrationID: UUID,
1937 eventID: UUID,
1938 timestamp: Date = Date(),
1939 status: DeliveryStatus,
1940 destination: String,
1941 summary: String,
1942 failureReason: String? = nil,
1943 attemptCount: Int = 0
1944 ) {
1945 self.id = id
1946 self.integrationID = integrationID
1947 self.eventID = eventID
1948 self.timestamp = timestamp
1949 self.status = status
1950 self.destination = destination
1951 self.summary = summary
1952 self.failureReason = failureReason
1953 self.attemptCount = attemptCount
1954 }
1955}
1956
1957struct QueuedDelivery: Codable, Identifiable, Equatable, Sendable {
1958 var id: UUID
1959 var integrationID: UUID
1960 var event: MonitoringEvent
1961 var createdAt: Date
1962 var attemptCount: Int
1963 var nextAttemptAt: Date
1964 var lastError: String?
1965 var expiresAt: Date
1966
1967 init(
1968 id: UUID = UUID(),
1969 integrationID: UUID,
1970 event: MonitoringEvent,
1971 createdAt: Date = Date(),
1972 attemptCount: Int = 0,
1973 nextAttemptAt: Date = Date(),
1974 lastError: String? = nil,
1975 expiresAt: Date = Date().addingTimeInterval(3 * 24 * 60 * 60)
1976 ) {
1977 self.id = id
1978 self.integrationID = integrationID
1979 self.event = event
1980 self.createdAt = createdAt
1981 self.attemptCount = attemptCount
1982 self.nextAttemptAt = nextAttemptAt
1983 self.lastError = lastError
1984 self.expiresAt = expiresAt
1985 }
1986}
1987
1988// MARK: - DNS Models
1989
1990enum DNSRecordType: String, CaseIterable, Codable {
1991 case A
1992 case AAAA
1993 case MX
1994 case NS
1995 case TXT
1996 case CNAME
1997 case SOA
1998 case SRV
1999 case CAA
2000 case DS
2001 case PTR
2002
2003 var queryType: Int {
2004 switch self {
2005 case .A: return 1
2006 case .AAAA: return 28
2007 case .MX: return 15
2008 case .NS: return 2
2009 case .TXT: return 16
2010 case .CNAME: return 5
2011 case .SOA: return 6
2012 case .SRV: return 33
2013 case .CAA: return 257
2014 case .DS: return 43
2015 case .PTR: return 12
2016 }
2017 }
2018
2019 var usesRawDataValue: Bool {
2020 switch self {
2021 case .TXT, .SOA, .DS:
2022 return true
2023 default:
2024 return false
2025 }
2026 }
2027}
2028
2029struct DNSRecord: Identifiable, Codable {
2030 var id = UUID()
2031 let value: String
2032 let ttl: Int
2033}
2034
2035struct DNSSection: Identifiable, Codable {
2036 var id = UUID()
2037 let recordType: DNSRecordType
2038 var records: [DNSRecord]
2039 var wildcardRecords: [DNSRecord] = []
2040 var dnssecSigned: Bool?
2041 var error: String?
2042}
2043
2044// MARK: - SSL Models
2045
2046struct SSLCertificateInfo: Codable {
2047 struct CertChainEntry: Codable {
2048 let subject: String
2049 let issuer: String
2050 }
2051
2052 let commonName: String
2053 let subjectAltNames: [String]
2054 let issuer: String
2055 let validFrom: Date
2056 let validUntil: Date
2057 let daysUntilExpiry: Int
2058 let chainDepth: Int
2059 let tlsVersion: String?
2060 let cipherSuite: String?
2061 let chain: [CertChainEntry]
2062
2063 init(
2064 commonName: String,
2065 subjectAltNames: [String],
2066 issuer: String,
2067 validFrom: Date,
2068 validUntil: Date,
2069 daysUntilExpiry: Int,
2070 chainDepth: Int,
2071 tlsVersion: String? = nil,
2072 cipherSuite: String? = nil,
2073 chain: [CertChainEntry] = []
2074 ) {
2075 self.commonName = commonName
2076 self.subjectAltNames = subjectAltNames
2077 self.issuer = issuer
2078 self.validFrom = validFrom
2079 self.validUntil = validUntil
2080 self.daysUntilExpiry = daysUntilExpiry
2081 self.chainDepth = chainDepth
2082 self.tlsVersion = tlsVersion
2083 self.cipherSuite = cipherSuite
2084 self.chain = chain
2085 }
2086
2087 init(from decoder: Decoder) throws {
2088 let container = try decoder.container(keyedBy: CodingKeys.self)
2089 commonName = try container.decode(String.self, forKey: .commonName)
2090 subjectAltNames = try container.decode([String].self, forKey: .subjectAltNames)
2091 issuer = try container.decode(String.self, forKey: .issuer)
2092 validFrom = try container.decode(Date.self, forKey: .validFrom)
2093 validUntil = try container.decode(Date.self, forKey: .validUntil)
2094 daysUntilExpiry = try container.decode(Int.self, forKey: .daysUntilExpiry)
2095 chainDepth = try container.decode(Int.self, forKey: .chainDepth)
2096 tlsVersion = try container.decodeIfPresent(String.self, forKey: .tlsVersion)
2097 cipherSuite = try container.decodeIfPresent(String.self, forKey: .cipherSuite)
2098 chain = try container.decodeIfPresent([CertChainEntry].self, forKey: .chain) ?? []
2099 }
2100}
2101
2102// MARK: - HTTP Headers Models
2103
2104struct HTTPHeader: Identifiable, Codable {
2105 var id = UUID()
2106 let name: String
2107 let value: String
2108
2109 static let securityHeaders: Set<String> = [
2110 "strict-transport-security",
2111 "x-frame-options",
2112 "x-content-type-options",
2113 "content-security-policy",
2114 "referrer-policy"
2115 ]
2116
2117 var isSecurityHeader: Bool {
2118 Self.securityHeaders.contains(name.lowercased())
2119 }
2120}
2121
2122// MARK: - Reachability Models
2123
2124struct PortReachability: Identifiable, Codable {
2125 var id = UUID()
2126 let port: UInt16
2127 let reachable: Bool
2128 let latencyMs: Int?
2129}
2130
2131// MARK: - IP Geolocation Models
2132
2133struct IPGeolocation: Codable {
2134 let ip: String
2135 let city: String?
2136 let region: String?
2137 let country_name: String?
2138 let org: String?
2139 let latitude: Double?
2140 let longitude: Double?
2141}
2142
2143// MARK: - Ownership Models
2144
2145struct DomainOwnership: Codable, Equatable {
2146 let registrar: String?
2147 let registrant: String?
2148 let createdDate: Date?
2149 let expirationDate: Date?
2150 let status: [String]
2151 let nameservers: [String]
2152 let abuseEmail: String?
2153
2154 init(
2155 registrar: String? = nil,
2156 registrant: String? = nil,
2157 createdDate: Date? = nil,
2158 expirationDate: Date? = nil,
2159 status: [String] = [],
2160 nameservers: [String] = [],
2161 abuseEmail: String? = nil
2162 ) {
2163 self.registrar = registrar
2164 self.registrant = registrant
2165 self.createdDate = createdDate
2166 self.expirationDate = expirationDate
2167 self.status = status
2168 self.nameservers = nameservers
2169 self.abuseEmail = abuseEmail
2170 }
2171
2172 init(from decoder: Decoder) throws {
2173 let container = try decoder.container(keyedBy: CodingKeys.self)
2174 registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
2175 registrant = try container.decodeIfPresent(String.self, forKey: .registrant)
2176 createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
2177 expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
2178 status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
2179 nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
2180 abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail)
2181 }
2182}
2183
2184struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable {
2185 var id: String { hostname }
2186 let hostname: String
2187 let source: String?
2188 let isExtended: Bool
2189
2190 nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) {
2191 self.hostname = hostname
2192 self.source = source
2193 self.isExtended = isExtended
2194 }
2195}
2196
2197// MARK: - Email Security Models
2198
2199struct EmailSecurityResult: Codable {
2200 let spf: EmailSecurityRecord
2201 let dmarc: EmailSecurityRecord
2202 let dkim: EmailSecurityRecord
2203 let bimi: EmailSecurityRecord
2204 let mtaSts: MTASTSResult?
2205
2206 init(
2207 spf: EmailSecurityRecord,
2208 dmarc: EmailSecurityRecord,
2209 dkim: EmailSecurityRecord,
2210 bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil),
2211 mtaSts: MTASTSResult? = nil
2212 ) {
2213 self.spf = spf
2214 self.dmarc = dmarc
2215 self.dkim = dkim
2216 self.bimi = bimi
2217 self.mtaSts = mtaSts
2218 }
2219
2220 init(from decoder: Decoder) throws {
2221 let container = try decoder.container(keyedBy: CodingKeys.self)
2222 spf = try container.decode(EmailSecurityRecord.self, forKey: .spf)
2223 dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc)
2224 dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim)
2225 bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi)
2226 ?? EmailSecurityRecord(found: false, value: nil)
2227 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts)
2228 }
2229}
2230
2231struct EmailSecurityRecord: Codable {
2232 let found: Bool
2233 let value: String?
2234 let matchedSelector: String?
2235
2236 init(found: Bool, value: String?, matchedSelector: String? = nil) {
2237 self.found = found
2238 self.value = value
2239 self.matchedSelector = matchedSelector
2240 }
2241
2242 init(from decoder: Decoder) throws {
2243 let container = try decoder.container(keyedBy: CodingKeys.self)
2244 found = try container.decode(Bool.self, forKey: .found)
2245 value = try container.decodeIfPresent(String.self, forKey: .value)
2246 matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector)
2247 }
2248}
2249
2250struct MTASTSResult: Codable {
2251 let txtFound: Bool
2252 let policyMode: String?
2253}
2254
2255// MARK: - Redirect Chain Models
2256
2257struct RedirectHop: Identifiable, Codable {
2258 var id = UUID()
2259 let stepNumber: Int
2260 let statusCode: Int
2261 let url: String
2262 let isFinal: Bool
2263}
2264
2265// MARK: - Port Scan Models
2266
2267enum PortScanKind: String, Codable {
2268 case standard
2269 case custom
2270}
2271
2272struct PortScanResult: Identifiable, Codable {
2273 var id = UUID()
2274 let port: UInt16
2275 let service: String
2276 let open: Bool
2277 var banner: String?
2278 let kind: PortScanKind
2279 let durationMs: Int?
2280
2281 nonisolated init(
2282 port: UInt16,
2283 service: String,
2284 open: Bool,
2285 banner: String? = nil,
2286 kind: PortScanKind = .standard,
2287 durationMs: Int? = nil
2288 ) {
2289 self.port = port
2290 self.service = service
2291 self.open = open
2292 self.banner = banner
2293 self.kind = kind
2294 self.durationMs = durationMs
2295 }
2296
2297 init(from decoder: Decoder) throws {
2298 let container = try decoder.container(keyedBy: CodingKeys.self)
2299 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2300 port = try container.decode(UInt16.self, forKey: .port)
2301 service = try container.decode(String.self, forKey: .service)
2302 open = try container.decode(Bool.self, forKey: .open)
2303 banner = try container.decodeIfPresent(String.self, forKey: .banner)
2304 kind = try container.decodeIfPresent(PortScanKind.self, forKey: .kind) ?? .standard
2305 durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs)
2306 }
2307}
2308
2309// MARK: - History Models
2310
2311struct HistoryEntry: Identifiable, Codable {
2312 var id = UUID()
2313 let domain: String
2314 let timestamp: Date
2315 var trackedDomainID: UUID?
2316 var note: String?
2317 let dnsSections: [DNSSection]
2318 let sslInfo: SSLCertificateInfo?
2319 let httpHeaders: [HTTPHeader]
2320 let reachabilityResults: [PortReachability]
2321 let ipGeolocation: IPGeolocation?
2322 var emailSecurity: EmailSecurityResult?
2323 var mtaSts: MTASTSResult?
2324 var ownership: DomainOwnership?
2325 var ownershipHistory: [DomainOwnershipHistoryEvent]
2326 var inferredProvider: InferredProviderFingerprint?
2327 var priorProviders: [String]
2328 var domainClassification: DomainClassificationSummary?
2329 var ownershipTransitions: [OwnershipTransitionEvent]
2330 var hostingTransitions: [HostingTransitionEvent]
2331 var subdomainHistory: [SubdomainHistoryEntry]
2332 var riskSignals: [IntelligenceRiskSignal]
2333 var intelligenceTimeline: [IntelligenceTimelineEvent]
2334 var ptrRecord: String?
2335 var redirectChain: [RedirectHop]
2336 var subdomains: [DiscoveredSubdomain]
2337 var extendedSubdomains: [DiscoveredSubdomain]
2338 var dnsHistory: [DNSHistoryEvent]
2339 var domainPricing: DomainPricingInsight?
2340 var reputation: DomainReputationResult?
2341 var portScanResults: [PortScanResult]
2342 var hstsPreloaded: Bool?
2343 var availabilityResult: DomainAvailabilityResult?
2344 var suggestions: [DomainSuggestionResult]
2345 var appVersion: String
2346 var resultSource: LookupResultSource
2347 var dataSources: [String]
2348 var provenanceBySection: [LookupSectionKind: SectionProvenance]
2349 var availabilityConfidence: ConfidenceLevel?
2350 var ownershipConfidence: ConfidenceLevel?
2351 var subdomainConfidence: ConfidenceLevel?
2352 var emailSecurityConfidence: ConfidenceLevel?
2353 var geolocationConfidence: ConfidenceLevel?
2354 var errorDetails: [LookupSectionKind: InspectionFailure]
2355 var isPartialSnapshot: Bool
2356 var validationIssues: [String]
2357 var resolverDisplayName: String
2358 var resolverURLString: String
2359 var totalLookupDurationMs: Int?
2360 var primaryIP: String?
2361 var finalRedirectURL: String?
2362 var tlsStatusSummary: String?
2363 var emailSecuritySummary: String?
2364 var httpGradeSummary: String?
2365 var changeSummary: DomainChangeSummary?
2366 var snapshotIndex: Int?
2367 var previousSnapshotID: UUID?
2368 var changeCount: Int
2369 var severitySummary: ChangeSeverity?
2370 var sslError: String?
2371 var httpHeadersError: String?
2372 var reachabilityError: String?
2373 var ipGeolocationError: String?
2374 var emailSecurityError: String?
2375 var ownershipError: String?
2376 var ownershipHistoryError: String?
2377 var ptrError: String?
2378 var redirectChainError: String?
2379 var subdomainsError: String?
2380 var extendedSubdomainsError: String?
2381 var dnsHistoryError: String?
2382 var domainPricingError: String?
2383 var reputationError: String?
2384 var portScanError: String?
2385
2386 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
2387 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
2388 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
2389 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
2390 ownershipHistory: [DomainOwnershipHistoryEvent] = [],
2391 inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [],
2392 domainClassification: DomainClassificationSummary? = nil,
2393 ownershipTransitions: [OwnershipTransitionEvent] = [],
2394 hostingTransitions: [HostingTransitionEvent] = [],
2395 subdomainHistory: [SubdomainHistoryEntry] = [],
2396 riskSignals: [IntelligenceRiskSignal] = [],
2397 intelligenceTimeline: [IntelligenceTimelineEvent] = [],
2398 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
2399 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
2400 domainPricing: DomainPricingInsight? = nil,
2401 reputation: DomainReputationResult? = nil,
2402 portScanResults: [PortScanResult] = [],
2403 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
2404 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
2405 resultSource: LookupResultSource = .snapshot, dataSources: [String] = [],
2406 provenanceBySection: [LookupSectionKind: SectionProvenance] = [:],
2407 availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil,
2408 subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil,
2409 geolocationConfidence: ConfidenceLevel? = nil,
2410 errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false,
2411 validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String,
2412 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
2413 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
2414 changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil,
2415 changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
2416 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
2417 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
2418 ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
2419 extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
2420 domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) {
2421 self.domain = domain
2422 self.timestamp = timestamp
2423 self.trackedDomainID = trackedDomainID
2424 self.note = note
2425 self.dnsSections = dnsSections
2426 self.sslInfo = sslInfo
2427 self.httpHeaders = httpHeaders
2428 self.reachabilityResults = reachabilityResults
2429 self.ipGeolocation = ipGeolocation
2430 self.emailSecurity = emailSecurity
2431 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
2432 self.ownership = ownership
2433 self.ownershipHistory = ownershipHistory
2434 self.inferredProvider = inferredProvider
2435 self.priorProviders = priorProviders
2436 self.domainClassification = domainClassification
2437 self.ownershipTransitions = ownershipTransitions
2438 self.hostingTransitions = hostingTransitions
2439 self.subdomainHistory = subdomainHistory
2440 self.riskSignals = riskSignals
2441 self.intelligenceTimeline = intelligenceTimeline
2442 self.ptrRecord = ptrRecord
2443 self.redirectChain = redirectChain
2444 self.subdomains = subdomains
2445 self.extendedSubdomains = extendedSubdomains
2446 self.dnsHistory = dnsHistory
2447 self.domainPricing = domainPricing
2448 self.reputation = reputation
2449 self.portScanResults = portScanResults
2450 self.hstsPreloaded = hstsPreloaded
2451 self.availabilityResult = availabilityResult
2452 self.suggestions = suggestions
2453 self.appVersion = appVersion
2454 self.resultSource = resultSource
2455 self.dataSources = dataSources
2456 self.provenanceBySection = provenanceBySection
2457 self.availabilityConfidence = availabilityConfidence
2458 self.ownershipConfidence = ownershipConfidence
2459 self.subdomainConfidence = subdomainConfidence
2460 self.emailSecurityConfidence = emailSecurityConfidence
2461 self.geolocationConfidence = geolocationConfidence
2462 self.errorDetails = errorDetails
2463 self.isPartialSnapshot = isPartialSnapshot
2464 self.validationIssues = validationIssues
2465 self.resolverDisplayName = resolverDisplayName
2466 self.resolverURLString = resolverURLString
2467 self.totalLookupDurationMs = totalLookupDurationMs
2468 self.primaryIP = primaryIP
2469 self.finalRedirectURL = finalRedirectURL
2470 self.tlsStatusSummary = tlsStatusSummary
2471 self.emailSecuritySummary = emailSecuritySummary
2472 self.httpGradeSummary = httpGradeSummary
2473 self.changeSummary = changeSummary
2474 self.snapshotIndex = snapshotIndex
2475 self.previousSnapshotID = previousSnapshotID
2476 self.changeCount = changeCount
2477 self.severitySummary = severitySummary
2478 self.sslError = sslError
2479 self.httpHeadersError = httpHeadersError
2480 self.reachabilityError = reachabilityError
2481 self.ipGeolocationError = ipGeolocationError
2482 self.emailSecurityError = emailSecurityError
2483 self.ownershipError = ownershipError
2484 self.ownershipHistoryError = ownershipHistoryError
2485 self.ptrError = ptrError
2486 self.redirectChainError = redirectChainError
2487 self.subdomainsError = subdomainsError
2488 self.extendedSubdomainsError = extendedSubdomainsError
2489 self.dnsHistoryError = dnsHistoryError
2490 self.domainPricingError = domainPricingError
2491 self.reputationError = reputationError
2492 self.portScanError = portScanError
2493 }
2494
2495 init(from decoder: Decoder) throws {
2496 let container = try decoder.container(keyedBy: CodingKeys.self)
2497 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
2498 domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain"
2499 timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast
2500 trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID)
2501 note = try container.decodeIfPresent(String.self, forKey: .note)
2502 dnsSections = try container.decodeIfPresent([DNSSection].self, forKey: .dnsSections) ?? []
2503 sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
2504 httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? []
2505 reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? []
2506 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
2507 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
2508 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
2509 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
2510 ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? []
2511 inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider)
2512 priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? []
2513 domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification)
2514 ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? []
2515 hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? []
2516 subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? []
2517 riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? []
2518 intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? []
2519 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
2520 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
2521 subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
2522 extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
2523 dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
2524 domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
2525 reputation = try container.decodeIfPresent(DomainReputationResult.self, forKey: .reputation)
2526 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
2527 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
2528 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
2529 suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? []
2530 appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "2.6.0"
2531 resultSource = try container.decodeIfPresent(LookupResultSource.self, forKey: .resultSource) ?? .snapshot
2532 dataSources = try container.decodeIfPresent([String].self, forKey: .dataSources) ?? []
2533 provenanceBySection = try container.decodeIfPresent([LookupSectionKind: SectionProvenance].self, forKey: .provenanceBySection) ?? [:]
2534 availabilityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .availabilityConfidence)
2535 ownershipConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .ownershipConfidence)
2536 subdomainConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .subdomainConfidence)
2537 emailSecurityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .emailSecurityConfidence)
2538 geolocationConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .geolocationConfidence)
2539 errorDetails = try container.decodeIfPresent([LookupSectionKind: InspectionFailure].self, forKey: .errorDetails) ?? [:]
2540 validationIssues = try container.decodeIfPresent([String].self, forKey: .validationIssues) ?? HistoryEntry.defaultValidationIssues(domain: domain, timestamp: timestamp)
2541 isPartialSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isPartialSnapshot) ?? !validationIssues.isEmpty
2542 resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
2543 resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
2544 totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
2545 primaryIP = try container.decodeIfPresent(String.self, forKey: .primaryIP)
2546 finalRedirectURL = try container.decodeIfPresent(String.self, forKey: .finalRedirectURL)
2547 tlsStatusSummary = try container.decodeIfPresent(String.self, forKey: .tlsStatusSummary)
2548 emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary)
2549 httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary)
2550 changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary)
2551 snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex)
2552 previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID)
2553 changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount)
2554 ?? changeSummary?.changedSections.count
2555 ?? 0
2556 severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary)
2557 ?? changeSummary?.severity
2558 sslError = try container.decodeIfPresent(String.self, forKey: .sslError)
2559 httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError)
2560 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
2561 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
2562 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
2563 ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
2564 ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError)
2565 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
2566 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
2567 subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
2568 extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
2569 dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
2570 domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
2571 reputationError = try container.decodeIfPresent(String.self, forKey: .reputationError)
2572 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
2573 }
2574
2575 private static func defaultValidationIssues(domain: String, timestamp: Date) -> [String] {
2576 var issues: [String] = []
2577 if domain == "unknown-domain" {
2578 issues.append("Missing domain in stored snapshot")
2579 }
2580 if timestamp == .distantPast {
2581 issues.append("Missing collection timestamp in stored snapshot")
2582 }
2583 return issues
2584 }
2585
2586 var snapshotSummary: SnapshotSummary {
2587 SnapshotSummary(
2588 id: id,
2589 domain: domain,
2590 timestamp: timestamp,
2591 trackedDomainID: trackedDomainID,
2592 snapshotIndex: snapshotIndex,
2593 previousSnapshotID: previousSnapshotID,
2594 changeCount: changeCount,
2595 severitySummary: severitySummary ?? changeSummary?.severity,
2596 changeSummaryMessage: changeSummary?.message,
2597 availability: availabilityResult?.status,
2598 primaryIP: primaryIP,
2599 tlsStatus: tlsStatusSummary,
2600 riskScore: changeSummary?.riskAssessment?.score,
2601 historyEntryID: id
2602 )
2603 }
2604}
2605
2606// MARK: - Cloudflare DNS-over-HTTPS Response
2607
2608struct CloudflareDNSResponse: Decodable {
2609 let Status: Int
2610 let AD: Bool?
2611 let Answer: [CloudflareDNSAnswer]?
2612
2613 struct CloudflareDNSAnswer: Decodable {
2614 let name: String
2615 let type: Int
2616 let TTL: Int
2617 let data: String
2618 }
2619}