Files
James Cottrill fbf26453f2 feat: initial public release
ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
2026-04-14 09:18:18 +00:00

129 lines
4.8 KiB
Swift

import Foundation
// MARK: - GCM Consent Types
/// The Google Consent Mode v2 consent type strings.
public enum GCMConsentType: String, CaseIterable, Sendable {
case analyticsStorage = "analytics_storage"
case adStorage = "ad_storage"
case adUserData = "ad_user_data"
case adPersonalisation = "ad_personalization"
case functionalityStorage = "functionality_storage"
case personalisationStorage = "personalization_storage"
case securityStorage = "security_storage"
}
/// The granted/denied status for a GCM consent type.
public enum GCMConsentStatus: String, Sendable {
case granted = "granted"
case denied = "denied"
}
// MARK: - GCM Analytics Provider Protocol
/// Abstracts the Firebase Analytics / Google Tag Manager call surface.
///
/// Conforming types should call the underlying GCM API. The default no-op
/// implementation is used when Firebase is not present.
public protocol GCMAnalyticsProvider: AnyObject, Sendable {
/// Sets the default consent state before any interaction.
func setConsentDefaults(_ defaults: [String: String])
/// Updates consent state after the user interacts with the banner.
func updateConsent(_ updates: [String: String])
}
// MARK: - No-Op Provider
/// A no-op ``GCMAnalyticsProvider`` used when Firebase Analytics is not linked.
public final class NoOpGCMAnalyticsProvider: GCMAnalyticsProvider, @unchecked Sendable {
public init() {}
public func setConsentDefaults(_ defaults: [String: String]) {}
public func updateConsent(_ updates: [String: String]) {}
}
// MARK: - GCM Bridge
/// Maps CMP consent state to Google Consent Mode v2 signals.
///
/// On app launch, call ``applyDefaults(config:)`` before the user interacts.
/// After consent is collected, call ``applyConsent(state:)`` to update GCM.
///
/// To integrate with Firebase Analytics, implement ``GCMAnalyticsProvider`` and
/// call the `Firebase.Analytics.setConsent(_:)` API inside it.
public final class GCMBridge: @unchecked Sendable {
// MARK: - Dependencies
private let provider: any GCMAnalyticsProvider
// MARK: - Initialiser
/// - Parameter provider: The analytics provider to forward consent signals to.
/// Defaults to a no-op implementation.
public init(provider: any GCMAnalyticsProvider = NoOpGCMAnalyticsProvider()) {
self.provider = provider
}
// MARK: - Public API
/// Sends default (pre-consent) consent signals to GCM based on the site's blocking mode.
///
/// Call this as early as possible ideally before any analytics events are sent.
///
/// - Parameter config: The effective site configuration.
public func applyDefaults(config: ConsentConfig) {
let defaults = buildDefaults(for: config.blockingMode)
provider.setConsentDefaults(defaults)
}
/// Updates GCM consent signals to reflect the user's explicit choices.
///
/// - Parameter state: The resolved consent state after user interaction.
public func applyConsent(state: ConsentState) {
let updates = buildConsentMap(from: state)
provider.updateConsent(updates)
}
// MARK: - Private Helpers
/// Builds the default GCM consent map based on blocking mode.
///
/// - `opt_in`: all types denied by default (GDPR).
/// - `opt_out`: all types granted by default (CCPA).
/// - `informational`: all types granted.
private func buildDefaults(for mode: ConsentConfig.BlockingMode) -> [String: String] {
let status: GCMConsentStatus = mode == .optIn ? .denied : .granted
return Dictionary(
uniqueKeysWithValues: GCMConsentType.allCases.map {
($0.rawValue, status.rawValue)
}
)
}
/// Maps the consent state's accepted/rejected categories to GCM consent type values.
private func buildConsentMap(from state: ConsentState) -> [String: String] {
var map: [String: String] = [:]
// security_storage is always granted it is necessary for security.
map[GCMConsentType.securityStorage.rawValue] = GCMConsentStatus.granted.rawValue
for category in ConsentCategory.allCases {
guard let gcmType = category.gcmConsentType else { continue }
let status: GCMConsentStatus = state.isGranted(category) ? .granted : .denied
map[gcmType] = status.rawValue
}
// ad_user_data and ad_personalization follow the marketing category.
let marketingGranted = state.isGranted(.marketing)
map[GCMConsentType.adUserData.rawValue] = marketingGranted
? GCMConsentStatus.granted.rawValue
: GCMConsentStatus.denied.rawValue
map[GCMConsentType.adPersonalisation.rawValue] = marketingGranted
? GCMConsentStatus.granted.rawValue
: GCMConsentStatus.denied.rawValue
return map
}
}