@tinkerlandai/indiana-react-native 0.9.2 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/IndianaReactNative.podspec +19 -0
  2. package/README.md +61 -4
  3. package/THIRD_PARTY_NOTICES.md +74 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
  7. package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
  8. package/dist/index.cjs +17 -1
  9. package/dist/index.d.cts +57408 -604
  10. package/dist/index.d.ts +57408 -604
  11. package/dist/index.js +17 -1
  12. package/ios/IndianaBridge.m +11 -0
  13. package/ios/IndianaBridge.swift +275 -0
  14. package/native-sdk/android/assets/indiana/index.html +268 -0
  15. package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
  16. package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
  17. package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
  18. package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
  19. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
  20. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +193 -0
  21. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
  22. package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
  23. package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
  24. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
  25. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
  26. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
  27. package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
  28. package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
  29. package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
  30. package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
  31. package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
  32. package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
  33. package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
  34. package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
  35. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
  36. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
  37. package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
  38. package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
  39. package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
  40. package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
  41. package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
  42. package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
  43. package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
  44. package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
  45. package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
  46. package/native-sdk/android/resources/renderer-data-url.txt +1 -0
  47. package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
  48. package/native-sdk/ios/AppleStorefront.swift +218 -0
  49. package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
  50. package/native-sdk/ios/BundledWebView.swift +87 -0
  51. package/native-sdk/ios/ContentSession.swift +48 -0
  52. package/native-sdk/ios/ContentView.swift +51 -0
  53. package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
  54. package/native-sdk/ios/CustomerResponseSemantics.swift +232 -0
  55. package/native-sdk/ios/CustomerSemantics.swift +145 -0
  56. package/native-sdk/ios/CustomerSession.swift +129 -0
  57. package/native-sdk/ios/FeatureClient.swift +86 -0
  58. package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
  59. package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
  60. package/native-sdk/ios/FunnelSession.swift +232 -0
  61. package/native-sdk/ios/FunnelView.swift +125 -0
  62. package/native-sdk/ios/GeneratedProtocol.swift +16 -0
  63. package/native-sdk/ios/HTTPTransport.swift +74 -0
  64. package/native-sdk/ios/IndianaClient.swift +601 -0
  65. package/native-sdk/ios/NativePendingKeychain.swift +52 -0
  66. package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
  67. package/native-sdk/ios/NativeStoreOffer.swift +34 -0
  68. package/native-sdk/ios/PaywallContent.swift +75 -0
  69. package/native-sdk/ios/PaywallSession.swift +124 -0
  70. package/native-sdk/ios/PaywallView.swift +87 -0
  71. package/native-sdk/ios/Protocol.swift +49 -0
  72. package/native-sdk/ios/PushInstallation.swift +154 -0
  73. package/native-sdk/ios/RendererNavigation.swift +8 -0
  74. package/native-sdk/ios/Resources.swift +15 -0
  75. package/native-sdk/ios/SDKVersion.swift +4 -0
  76. package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
  77. package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
  78. package/native-sdk/ios/WebView/index.html +268 -0
  79. package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
  80. package/native-sdk/ios/WireSchema.swift +107 -0
  81. package/native-sdk/ios/funnel-wire-schemas.json +1 -0
  82. package/package.json +8 -3
  83. package/react-native.config.cjs +12 -0
@@ -0,0 +1,232 @@
1
+ import Foundation
2
+
3
+ // Dispatch uses generated schema fields, so merchant JSON cannot impersonate a DTO.
4
+ enum IndianaCustomerResponseSemantics {
5
+ typealias O = [String: Any]
6
+ static func require(_ value: Bool) throws { if !value { throw IndianaClientError.invalidResponse } }
7
+ static func o(_ value: Any?) -> O { value as? O ?? [:] }
8
+ static func a(_ value: Any?) -> [O] { value as? [O] ?? [] }
9
+ static func s(_ value: Any?) -> String { value as? String ?? "" }
10
+ static func n(_ value: Any?) -> Decimal { if let text = value as? String { return Decimal(string: text, locale: Locale(identifier: "en_US_POSIX")) ?? 0 }; return (value as? NSNumber)?.decimalValue ?? 0 }
11
+ static func nilValue(_ value: Any?) -> Bool { value == nil || value is NSNull }
12
+ static func unique(_ values: [String]) -> Bool { Set(values).count == values.count }
13
+ static func time(_ value: Any?) -> TimeInterval { IndianaDates.parse(s(value))?.timeIntervalSince1970 ?? 0 }
14
+ static func rules(_ original: Any, schema: O) throws -> Any {
15
+ var value = original
16
+ let rules = Set(schema["x-indiana-rules"] as? [String] ?? [])
17
+ if rules.contains("trim"), let text = value as? String { value = text.trimmingCharacters(in: CharacterSet(charactersIn: "\u{9}\u{a}\u{b}\u{c}\u{d}\u{20}\u{a0}\u{1680}\u{2000}\u{2001}\u{2002}\u{2003}\u{2004}\u{2005}\u{2006}\u{2007}\u{2008}\u{2009}\u{200a}\u{2028}\u{2029}\u{202f}\u{205f}\u{3000}\u{feff}")) }
18
+ if rules.contains("positive") { try require(n(value) > 0) }; if rules.contains("atLeastOne") { try require(n(value) >= 1) }
19
+ if rules.contains("lowercase") { try require(s(value) == s(value).lowercased()) }
20
+ if rules.contains("https") { let url = URL(string: s(value)); try require(url?.scheme == "https" && url?.host?.isEmpty == false) }
21
+ if rules.contains("locale") { let parts = s(value).split(separator: "-").map(String.init); try require(!IndianaProtocol.nonCanonicalLocaleLanguages.contains(parts.first ?? "") && !parts.dropFirst().contains(where: { $0.count == 4 ? IndianaProtocol.nonCanonicalLocaleScripts.contains($0) : IndianaProtocol.nonCanonicalLocaleRegions.contains($0) })) }
22
+ if rules.contains("notNegativeZero") { try require(!s(value).hasPrefix("-") || n(value) != 0) }
23
+ if rules.contains("boundedAdjustment") { try require(s(value) != "-0" && n(value) >= -200000000 && n(value) <= 200000000) }
24
+ if rules.contains("orderedPair"), let pair = value as? [NSNumber], pair.count == 2 { try require(pair[0].doubleValue <= pair[1].doubleValue) }
25
+ if !rules.isDisjoint(with: ["jsonBudget", "attributes", "condition"]) { try jsonBudget(value); if !rules.isDisjoint(with: ["attributes", "condition"]) { try require(o(value).count <= 128) } }
26
+ if rules.contains("condition") { try condition(o(value)) }
27
+ return value
28
+ }
29
+ static func jsonBudget(_ value: Any) throws {
30
+ var count = 0
31
+ func visit(_ value: Any, _ depth: Int) throws {
32
+ count += 1; try require(count <= 10000 && depth <= 20)
33
+ if let text = value as? String { try require(!text.contains("\0")) }
34
+ if let list = value as? [Any] { for child in list { try visit(child, depth + 1) } }
35
+ if let object = value as? O { for (key, child) in object { try require(!["__proto__", "constructor", "prototype"].contains(key) && !key.contains("\0")); try visit(child, depth + 1) } }
36
+ }
37
+ try visit(value, 0); try require(try JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed, .withoutEscapingSlashes]).count <= 65536)
38
+ }
39
+ static func condition(_ value: O) throws {
40
+ let operators: Set<String> = ["$in", "$ini", "$inGroup", "$nin", "$nini", "$notInGroup", "$gt", "$gte", "$lt", "$lte", "$regex", "$regexi", "$ne", "$eq", "$size", "$elemMatch", "$all", "$alli", "$not", "$type", "$exists", "$vgt", "$vgte", "$vlt", "$vlte", "$vne", "$veq", "$and", "$or", "$nor", "$savedGroup"]
41
+ try require(!["all", "any", "group", "parameter", "answer", "attribute", "result"].contains(s(value["source"])) || value["operator"] == nil && value["predicates"] == nil && value["groupId"] == nil)
42
+ func visit(_ current: Any) throws {
43
+ if let list = current as? [Any] { for child in list { try visit(child) } }
44
+ if let object = current as? O { for (key, child) in object {
45
+ if key == "$savedGroup" { let group = o(child); try require(Set(group.keys).isSubset(of: ["id", "attributeKey"]) && UUID(uuidString: s(group["id"])) != nil && (group["attributeKey"] == nil || !s(group["attributeKey"]).isEmpty && s(group["attributeKey"]).utf16.count <= 128)) }
46
+ if key.hasPrefix("$") { try require(operators.contains(key)) } else { try require(key.split(separator: ".").allSatisfy { !["__proto__", "constructor", "prototype"].contains(String($0)) }) }
47
+ if ["$regex", "$regexi"].contains(key), let pattern = child as? String {
48
+ try require(pattern.range(of: "\\(\\?[<!=]|\\\\[0-9]|\\\\k<", options: .regularExpression) == nil)
49
+ let matches = try NSRegularExpression(pattern: "\\{([0-9]+)(?:,([0-9]*))?\\}").matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern))
50
+ for match in matches { for index in [1, 2] { if let range = Range(match.range(at: index), in: pattern), let bound = Int(pattern[range]) { try require(bound <= 255) } } }
51
+ if key == "$regexi" { try require(pattern.unicodeScalars.allSatisfy { $0.value <= 127 }) }
52
+ // With no Unicode flag, JavaScript treats unknown ASCII letter
53
+ // escapes as identity escapes; ICU/JVM would reject e.g. \q.
54
+ let chars = Array(pattern); var syntax = "", index = 0
55
+ while index < chars.count { if chars[index] == "\\", index + 1 < chars.count { let next = chars[index + 1]; if next.isASCII && next.isLetter && !"bBcdDsSwWfnrtvux".contains(next) { syntax.append(next) } else { syntax.append(chars[index]); syntax.append(next) }; index += 2 } else { syntax.append(chars[index]); index += 1 } }
56
+ _ = try NSRegularExpression(pattern: syntax)
57
+ }
58
+ try visit(child)
59
+ } }
60
+ }; try visit(value)
61
+ }
62
+ static func object(_ input: O, fields: Set<String>, path: [String]) throws -> O {
63
+ var v = input
64
+ func has(_ keys: [String]) -> Bool { Set(keys).isSubset(of: fields) }
65
+ if has(["productKind", "seatBundle", "applePromotionalOffer", "replacement", "offerKind"]) {
66
+ let apple = s(v["provider"]) == "apple", member = s(v["productKind"]) == "membership", kind = s(v["offerKind"]), replacement = o(v["replacement"]), proof = o(v["applePromotionalOffer"])
67
+ if !replacement.isEmpty { try require(member && kind == "none" && nilValue(v["trialDays"]) && s(replacement["policy"]) == (apple ? "apple_store" : "google_full_price")) }
68
+ if !member { try require(nilValue(v["trialDays"]) && kind == "none" && v["basePlanId"] == nil) }
69
+ else if !apple { try require(v["basePlanId"] != nil) }
70
+ try require(n(v["discountMinor"]) <= n(v["catalogAmountMinor"]) && unique(a(v["seatBundle"]).map { s($0["key"]) }))
71
+ try require(nilValue(v["trialDays"]) || n(v["discountMinor"]) == 0 && kind != "none")
72
+ try require(kind != "none" || n(v["discountMinor"]) == 0)
73
+ if apple {
74
+ try require(!nilValue(v["appAccountToken"]) && v["obfuscatedAccountId"] == nil && v["obfuscatedProfileId"] == nil && v["basePlanId"] == nil)
75
+ try require((kind == "promotional") == !nilValue(v["applePromotionalOffer"]) && (kind == "promotional") == (v["offerId"] != nil))
76
+ try require(proof.isEmpty || s(proof["offerId"]) == s(v["offerId"]))
77
+ } else { try require(v["appAccountToken"] == nil && !s(v["obfuscatedAccountId"]).isEmpty && !s(v["obfuscatedProfileId"]).isEmpty && nilValue(v["applePromotionalOffer"]) && (kind == "none") == (v["offerId"] == nil)) }
78
+ }
79
+ if has(["kind", "tiers"]), ["graduated", "volume"].contains(s(v["kind"])) {
80
+ let rows = a(v["tiers"]); try require(!rows.isEmpty && nilValue(rows.last?["upTo"]))
81
+ for (index, row) in rows.dropLast().enumerated() { try require(!nilValue(row["upTo"]) && (index == 0 || n(row["upTo"]) > n(rows[index - 1]["upTo"]))) }
82
+ try require(!rows.contains { n($0["flatAmountMinor"]) > 0 } || s(v["kind"]) == "volume" && rows.count > 1)
83
+ }
84
+ if has(["units", "prices", "minimumQuantity", "maximumQuantity"]) {
85
+ try require(n(v["units"]) > 0 && n(v["maximumQuantity"]) >= n(v["minimumQuantity"]))
86
+ let prices = a(v["prices"]); try require(unique(prices.map { s($0["currency"]) }))
87
+ for price in prices {
88
+ try require(price["pricing"] == nil || n(price["amountMinor"]) == 0)
89
+ let bounds = [n(v["maximumQuantity"])] + a(o(price["pricing"])["tiers"]).filter { !nilValue($0["upTo"]) }.flatMap { [n($0["upTo"]), n($0["upTo"]) + 1] }
90
+ for quantity in bounds where quantity <= n(v["maximumQuantity"]) { _ = try prepaid(price, quantity) }
91
+ }
92
+ }
93
+ if has(["terms", "includedAmount", "priceId", "productId", "unitAmountMinor"]) {
94
+ let terms = o(v["terms"]), quantity = n(v["quantity"])
95
+ try require(quantity >= n(terms["minimumQuantity"]) && quantity <= n(terms["maximumQuantity"]) && nilValue(v["productId"]) == nilValue(v["priceId"]))
96
+ try require(quantity == 0 || v["pricing"] == nil && n(v["unitAmountMinor"]) == 0 || !nilValue(v["priceId"]))
97
+ try require(n(v["includedAmount"]) + n(terms["units"]) * quantity <= Decimal(string: "999999999999.999999")!)
98
+ _ = try prepaid(v, quantity)
99
+ }
100
+ if has(["lineId", "baseAmountMinor", "catalogAmountMinor", "seats", "trial"]) {
101
+ let seats = a(v["seats"]); try require(unique(seats.map { s($0["key"]) }))
102
+ try require(seats.allSatisfy { n($0["quantity"]) >= n($0["includedQuantity"]) && n($0["quantity"]) <= n($0["maximumQuantity"]) })
103
+ var total = n(v["baseAmountMinor"]) + seats.reduce(0) { $0 + n($1["unitAmountMinor"]) * (n($1["quantity"]) - n($1["includedQuantity"])) }
104
+ for row in a(v["prepaid"]) { total += try prepaid(row, n(row["quantity"])) }; try require(total == n(v["catalogAmountMinor"]))
105
+ }
106
+ if has(["percent_off", "amount_off", "currency", "duration_in_months", "duration"]) { try require((v["percent_off"] != nil) != (v["amount_off"] != nil) && (v["amount_off"] == nil) == (v["currency"] == nil) && (s(v["duration"]) == "repeating") == (v["duration_in_months"] != nil)) }
107
+ if has(["operationId", "lines", "discount", "charge", "nativeOffer", "execution"]) { try attachQuote(v) }
108
+ if has(["executor", "checkoutUrl", "execution"]) {
109
+ try require(nilValue(v["execution"]) || s(v["executor"]) == "merchant")
110
+ if has(["quoteFinalized", "orderId"]) { try require(nilValue(v["checkoutUrl"]) || s(v["executor"]) == "indiana") }
111
+ if !nilValue(v["checkoutUrl"]) { try require(URL(string: s(v["checkoutUrl"]))?.scheme == "https") }
112
+ }
113
+ if has(["action", "executor", "update", "cancel", "schedule", "release"]) {
114
+ for action in ["update", "cancel", "schedule", "release"] { try require(!nilValue(v[action]) == (s(v["action"]) == action)) }
115
+ try require(s(v["executor"]) == "indiana" || (a(o(v["update"])["items"]) + a(o(v["schedule"])["nextItems"])).allSatisfy { !nilValue($0["price"]) })
116
+ }
117
+ if has(["offerKind", "offerId", "discountMinor", "trialDays"]), fields.count == 4 { try require((s(v["offerKind"]) != "none" || nilValue(v["offerId"]) && n(v["discountMinor"]) == 0 && nilValue(v["trialDays"])) && (nilValue(v["trialDays"]) || n(v["discountMinor"]) == 0)) }
118
+ if has(["lookupKey", "displayName", "items", "metadata"]) { v = try offering(v) }
119
+ if has(["tiers", "assignments"]) { let tiers = a(v["tiers"]).map { s($0["id"]) }, assignments = a(v["assignments"]); try require(unique(tiers) && unique(assignments.map { s($0["productVersionId"]) }) && unique(assignments.map { s($0["tierId"]) + ":" + s($0["period"]) }) && assignments.allSatisfy { tiers.contains(s($0["tierId"])) }) }
120
+ if has(["kind", "screen", "contract", "name"]) {
121
+ let screen = o(v["screen"]), kind = s(v["kind"]), actions = a(screen["actions"]), outputs = o(o(v["contract"])["outputs"])
122
+ try require(s(screen["surface"]) == kind && s(screen["name"]) == s(v["name"]) && (kind == "banner") == (s(o(screen["presentation"])["type"]) == "inline") && actions.allSatisfy { ["event", "navigate"].contains(s($0["type"])) })
123
+ try require(Set(actions.filter { s($0["type"]) == "event" }.map { s($0["id"]) }) == Set(outputs.keys))
124
+ try require(try JSONSerialization.data(withJSONObject: v, options: [.withoutEscapingSlashes]).count <= 300000)
125
+ }
126
+ if has(["pages", "actions", "initialPageId", "strings", "surface"]) { try screen(v) }
127
+ if has(["type", "planIds", "label", "fallback"]), s(v["type"]) == "plan_picker" {
128
+ if let ids = v["planIds"] as? [String] { try require(unique(ids)) }
129
+ if let group = v["grouping"] as? O { try require(productGrouping(group) == Set(v["planIds"] as? [String] ?? [])) }
130
+ }
131
+ if has(["type", "source", "expired", "granularity"]), s(v["type"]) == "countdown" { try require(s(v["source"]) != "fixed" || !nilValue(v["at"])) }
132
+ if has(["minimumQuantity", "maximumQuantity", "defaultQuantity", "step"]) { let q = n(v["defaultQuantity"]), min = n(v["minimumQuantity"]), step = n(v["step"]); let quotient = NSDecimalNumber(decimal: (q - min) / step); try require(q >= min && q <= n(v["maximumQuantity"]) && quotient.doubleValue.rounded() == quotient.doubleValue) }
133
+ if has(["id", "event", "properties", "type", "close"]) { try require(o(v["properties"]).count <= 20) }
134
+ if has(["transactionIds", "membership"]) { try require(unique(v["transactionIds"] as? [String] ?? [])) }
135
+ if has(["defaultLocale", "locales"]), path.last == "displayCopy" { let locales = o(v["locales"]); try require(!locales.isEmpty && locales.count <= 20 && locales[s(v["defaultLocale"])] != nil); try require(try JSONSerialization.data(withJSONObject: v, options: [.withoutEscapingSlashes]).count <= 50000) }
136
+ if has(["kind", "threshold", "quantity", "purchaseUnits"]) { try require(n(v["quantity"]) >= 1 && n(v["purchaseUnits"]) > 0) }
137
+ if has(["kind", "cashPrice", "currency"]) { try require(s(o(v["cashPrice"])["currency"]) == s(v["currency"])) }
138
+ if has(["currency", "pricing", "billingCycle"]) { let pricing = o(v["pricing"]); try require(s(pricing["kind"]) != "volume" || a(pricing["tiers"]).count == 1) }
139
+ if has(["status", "terms", "acceptedAt", "effectiveAt", "revokedAt"]) { let status = s(v["status"]); try require((status != "prepared" || nilValue(v["acceptedAt"])) && (status != "active" || !nilValue(v["acceptedAt"]) && !nilValue(v["effectiveAt"])) && (status == "revoked") == !nilValue(v["revokedAt"])) }
140
+ if has(["basis", "thresholds", "limit"]) { for field in ["thresholds", "includedThresholds", "absoluteThresholds"] { let values = v[field] as? [Any] ?? []; for index in values.indices.dropFirst() { try require(n(values[index]) > n(values[index - 1])) } } }
141
+ if has(["basis", "used", "reserved"]) && s(v["basis"]) == "usage" { for key in ["used", "reserved"] { try require(s(v[key]).range(of: "^(0|[1-9][0-9]{0,11})(\\.[0-9]{1,6})?$", options: .regularExpression) != nil) } }
142
+ if has(["amountMinor", "requestId", "reason"]), let amount = v["amountMinor"] as? String { try require(amount != "-0" && n(amount) >= -200000000 && n(amount) <= 200000000) }
143
+ if has(["inputs", "outputs"]), path.last == "contract" { try require(o(v["inputs"]).count <= 40 && o(v["outputs"]).count <= 40) }
144
+ if has(["formatVersion", "kind", "sha256", "url"]), path.contains("paywall") { try require(s(v["kind"]) == "paywall" && n(v["formatVersion"]) == 1) }
145
+ if has(["changeKind", "seat", "feature", "effectiveAt"]) { try require((s(v["changeKind"]) == "seats") == !nilValue(v["seat"]) && (s(v["changeKind"]) == "quantity") == (v["feature"] != nil)) }
146
+ if has(["usage", "capacity", "remaining", "withinLimit", "operationId"]) {
147
+ try require(nilValue(v["capacity"]) == nilValue(v["remaining"]))
148
+ try require(nilValue(v["capacity"]) ? v["withinLimit"] as? Bool == true : n(v["remaining"]) == n(v["capacity"]) - n(v["usage"]) && v["withinLimit"] as? Bool == (n(v["remaining"]) >= 0))
149
+ try require((n(v["revision"]) == 0) == nilValue(v["operationId"]) && nilValue(v["operationId"]) == nilValue(v["updatedAt"]))
150
+ }
151
+ if has(["usageType", "accessible", "available", "held", "debt", "usage"]) {
152
+ try require((s(v["kind"]) == "metered") == !nilValue(v["usageType"]) && (v["accessible"] as? Bool == true || s(v["available"]) == "0" && s(v["held"]) == "0"))
153
+ let usage = o(v["usage"])
154
+ if s(v["usageType"]) == "non_consumable" { let remaining = usage["remaining"]; try require(!usage.isEmpty && s(usage["internal_feature_id"]) == s(v["internal_feature_id"]) && v["unlimited"] as? Bool == nilValue(usage["capacity"]) && s(v["available"]) == (nilValue(remaining) || s(remaining).hasPrefix("-") ? "0" : s(remaining)) && s(v["held"]) == "0" && s(v["debt"]) == "0") }
155
+ else { try require(nilValue(v["usage"])) }
156
+ }
157
+ if has(["customerId", "rows", "nextCursor"]), path.contains("ledger") {
158
+ let rows = a(v["rows"]); try require(unique(rows.map { s($0["type"]) + s($0["id"]) }))
159
+ if let cursor = v["nextCursor"] as? O { let last = rows.last ?? [:]; try require(s(last["id"]) == s(cursor["id"]) && s(last["type"]) == s(cursor["type"]) && s(last["occurredAt"]) == s(cursor["at"])) }
160
+ }
161
+ if has(["kind", "accepted", "charges", "reservationStatus", "quantity"]), s(v["type"]) == "usage" {
162
+ let zero = s(v["kind"]) == "set_usage" || s(v["kind"]) == "consume" && s(v["quantity"]) == "0", accepted = v["accepted"] as? Bool == true, charges = a(v["charges"])
163
+ try require(zero ? accepted && nilValue(v["reason"]) && charges.isEmpty && nilValue(v["reservationStatus"]) : accepted ? nilValue(v["reason"]) && !charges.isEmpty : !nilValue(v["reason"]) && charges.isEmpty)
164
+ try require(!nilValue(v["originalId"]) == ["confirm", "refund", "release", "expire"].contains(s(v["kind"])) && nilValue(v["expiresAt"]) == nilValue(v["reservationStatus"]))
165
+ }
166
+ if has(["purpose", "productVersionId", "productName", "invoiceId", "originalId"]), s(v["type"]) == "transaction" {
167
+ let product = s(v["purpose"]) == "product"; try require(nilValue(v["currency"]) == nilValue(v["amountMinor"]) && product == !nilValue(v["productVersionId"]) && product == !nilValue(v["productName"]) && product == !nilValue(v["productVersion"]) && product == nilValue(v["invoiceId"]) && (s(v["kind"]) == "refund") == !nilValue(v["originalId"]) && (s(v["kind"]) != "refund" || nilValue(v["amountMinor"]) || n(v["amountMinor"]) > 0))
168
+ }
169
+ if has(["net", "internal_feature_id"]), s(v["net"]).hasPrefix("-") { try require(n(v["net"]) != 0) }
170
+ if has(["funding", "units"]) {
171
+ try require(n(v["units"]) > 0)
172
+ if s(v["funding"]) == "prepaid" {
173
+ let pricing = a(v["pricing"]); try require(pricing.reduce(0) { $0 + n($1["units"]) } == n(v["units"]) && pricing.reduce(0) { $0 + n($1["amount"]) } == n(v["amount"]) && pricing.allSatisfy { n($0["unitCost"]) * n($0["units"]) == n($0["amount"]) })
174
+ }
175
+ }
176
+ if has(["allowed", "allocations", "quantity", "internal_feature_id"]), v["allowed"] as? Bool == true {
177
+ let rows = a(v["allocations"]), delta = rows.reduce(0) { $0 + n($1["units"]) } - n(v["quantity"]), tolerance: Decimal = rows.contains { s($0["funding"]) == "balance" } ? Decimal(string: "0.0000000001")! : 0
178
+ try require(delta >= -tolerance && delta <= tolerance)
179
+ }
180
+ return v
181
+ }
182
+ static func prepaid(_ price: O, _ quantity: Decimal) throws -> Decimal {
183
+ if quantity == 0 { return 0 }; var total: Decimal = 0; let pricing = o(price["pricing"])
184
+ if pricing.isEmpty { total = n(price["amountMinor"] ?? price["unitAmountMinor"]) * quantity }
185
+ else { var lower: Decimal = 0; for row in a(pricing["tiers"]) { let upper = nilValue(row["upTo"]) ? 10000 : n(row["upTo"]); if s(pricing["kind"]) == "volume", quantity <= upper { total = quantity * n(row["unitAmountMinor"]) + n(row["flatAmountMinor"]); break }; if s(pricing["kind"]) != "volume", quantity > lower { total += (min(quantity, upper) - lower) * n(row["unitAmountMinor"]) }; lower = upper } }
186
+ try require(total <= 200000000); return total
187
+ }
188
+ static func attachQuote(_ v: O) throws {
189
+ let lines = a(v["lines"]), trial = o(lines.first?["trial"]), execution = o(v["execution"]), discount = o(v["discount"]), provider = s(v["provider"])
190
+ if provider == "stripe" {
191
+ if trial["cardRequired"] as? Bool == false && o(execution["invoiceMode"])["enabled"] as? Bool != true { try require(execution.isEmpty && discount.isEmpty) }
192
+ else {
193
+ try require(!execution.isEmpty && s(o(execution["metadata"])["grooveCheckoutId"]) == s(v["operationId"]) && time(execution["expiresAt"]) <= time(v["expiresAt"]))
194
+ try require(s(execution["mode"]) == (lines.contains { !["one_time", "free"].contains(s($0["interval"])) } ? "subscription" : "payment"))
195
+ try require(nilValue(execution["trialPeriodDays"]) == trial.isEmpty && (trial.isEmpty || n(execution["trialPeriodDays"]) == n(trial["days"])))
196
+ try require(s(execution["executor"]) == "indiana" || a(execution["lineItems"]).allSatisfy { !nilValue($0["price"]) })
197
+ let discounts = a(execution["discounts"]); try require(discounts.count == (discount.isEmpty ? 0 : 1))
198
+ for row in discounts { let terms = o(row["terms"]), coupon = s(row["coupon"]); try require(coupon == s(terms["id"]) && !discount.isEmpty); if coupon != s(discount["couponId"]) { try require(s(o(discount["discount"])["type"]) == "amount" && coupon == s(discount["couponId"]) + "_credit_" + s(discount["offerId"]) + "_" + String(describing: terms["amount_off"]!) && n(terms["amount_off"]) == n(o(discount["discount"])["amountOffCents"]) && s(terms["currency"]).uppercased() == s(v["currency"]) && s(terms["duration"]) == "once") } }
199
+ }
200
+ try require(v["nativeOffer"] == nil)
201
+ } else { let selection = o(v["nativeOffer"]); try require(execution.isEmpty && !selection.isEmpty && lines.count == 1 && n(selection["discountMinor"]) <= n(v["catalogAmountMinor"]) && nilValue(selection["trialDays"]) == trial.isEmpty && (trial.isEmpty || n(selection["trialDays"]) == n(trial["days"])) && (discount.isEmpty || n(selection["discountMinor"]) == n(discount["amountMinor"]))) }
202
+ if !discount.isEmpty {
203
+ let allocations = a(discount["allocations"]); try require(nilValue(v["billingSubscriptionId"]) && (provider == "stripe" || s(o(discount["duration"])["type"]) == "once") && n(discount["amountMinor"]) <= n(v["catalogAmountMinor"]) && allocations.count == lines.count)
204
+ for (row, line) in zip(allocations, lines) { try require(s(row["productVersionId"]) == s(line["productVersionId"]) && n(row["amountMinor"]) <= n(line["catalogAmountMinor"])) }; try require(allocations.reduce(0) { $0 + n($1["amountMinor"]) } == n(discount["amountMinor"]))
205
+ }
206
+ if let charge = v["charge"] as? O { try require(!nilValue(v["billingSubscriptionId"]) && n(charge["totalMinor"]) == n(charge["debitMinor"]) - n(charge["discountMinor"]) + n(charge["taxMinor"]) && n(charge["discountMinor"]) <= n(charge["debitMinor"]) && time(charge["periodEndsAt"]) > time(charge["effectiveAt"])) }
207
+ try require(unique(lines.map { s($0["lineId"]) }) && lines.reduce(0) { $0 + n($1["catalogAmountMinor"]) } == n(v["catalogAmountMinor"]))
208
+ }
209
+ static func offering(_ input: O) throws -> O {
210
+ var v = input; var products = Set<String>(), groups = Set<String>(), count = 0
211
+ func visit(_ items: [O]) throws -> [O] { try items.map { source in var item = source; count += 1; if s(item["type"]) == "product" { let id = s(item["productId"]); try require(id == id.lowercased() && products.insert(id).inserted) } else { try require(groups.insert(s(item["key"])).inserted); item["name"] = s(item["name"]).trimmingCharacters(in: .whitespacesAndNewlines); item["items"] = try visit(a(item["items"])) }; return item } }
212
+ v["items"] = try visit(a(v["items"])); try require(products.count <= 20 && count <= 60)
213
+ let bytes = try JSONSerialization.data(withJSONObject: o(v["metadata"]), options: [.withoutEscapingSlashes]); try require(String(data: bytes, encoding: .utf8)!.unicodeScalars.count <= 4000)
214
+ for field in ["displayName", "lookupKey"] { v[field] = s(v[field]).trimmingCharacters(in: .whitespacesAndNewlines) }; return v
215
+ }
216
+ static func productGrouping(_ group: O) -> Set<String> { Set(a(group["assignments"]).map { s($0["productVersionId"]) }) }
217
+ static func screen(_ v: O) throws {
218
+ let pages = a(v["pages"]), actions = a(v["actions"]), strings = o(v["strings"]), locales = Set(o(strings["locales"]).keys), pageIds = pages.map { s($0["id"]) }, actionIds = actions.map { s($0["id"]) }
219
+ try require((s(v["surface"]) == "paywall" || pages.count == 1) && pages.count <= 10 && actions.count <= 40 && locales.count <= 20 && locales.contains(s(strings["defaultLocale"])) && unique(pageIds) && unique(actionIds) && !pageIds.contains("next") && !pageIds.contains("back"))
220
+ try require(nilValue(v["initialPageId"]) || pageIds.contains(s(v["initialPageId"])))
221
+ for action in actions where s(action["type"]) == "navigate" { try require(s(v["surface"]) == "paywall" && pageIds.contains(s(action["pageId"]))) }
222
+ var ids = Set<String>(), count = 0
223
+ func visit(_ node: O, _ depth: Int) throws {
224
+ count += 1; try require(depth <= 8 && count <= 150 && ids.insert(s(node["id"])).inserted)
225
+ if s(node["type"]) == "button" { try require(actionIds.contains(s(node["action"]))) }
226
+ if s(node["type"]) == "custom_html" { try require(Set(o(node["html"]).keys).isSubset(of: locales)) }
227
+ if s(node["type"]) == "image" { try require(Set(o(node["localizedSrc"]).keys).isSubset(of: locales)) }
228
+ for child in a(node["children"]) { try visit(child, depth + 1) }
229
+ }
230
+ for page in pages { try visit(o(page["root"]), 1) }
231
+ }
232
+ }
@@ -0,0 +1,145 @@
1
+ import Foundation
2
+
3
+ // Adapters for current Customer Zod refinements/transforms that JSON Schema cannot encode.
4
+ // Structural validation remains generated from the original Contracts.
5
+ enum IndianaCustomerSemantics {
6
+ static func preflight(_ operation: String, _ value: [String: IndianaJSON]) throws {
7
+ if operation == "evaluateFeatures", let attributes = value["attributes"]?.object {
8
+ guard attributes.count <= 128 else { throw IndianaClientError.invalidConfiguration }; try jsonBudget(.object(attributes))
9
+ }
10
+ if operation == "trackEvents" { for event in array(value["events"]) { for child in event.object?["properties"]?.object?.values ?? Dictionary<String, IndianaJSON>().values { try jsonBudget(child) } } }
11
+ }
12
+ static func defaults(_ operation: String, _ input: [String: IndianaJSON]) -> [String: IndianaJSON] {
13
+ var input = input
14
+ if ["previewAttach", "previewUpdate"].contains(operation), var invoice = input["invoiceMode"]?.object {
15
+ if invoice["enable_plan_immediately"] == nil { invoice["enable_plan_immediately"] = .bool(false) }
16
+ if invoice["finalize"] == nil { invoice["finalize"] = .bool(true) }
17
+ input["invoiceMode"] = .object(invoice)
18
+ }
19
+ if operation == "trackEvents", case .array(let events)? = input["events"] {
20
+ input["events"] = .array(events.map { event in
21
+ guard var value = event.object else { return event }
22
+ if value["properties"] == nil { value["properties"] = .object([:]) }; return .object(value)
23
+ })
24
+ }
25
+ return input
26
+ }
27
+ static func request(_ operation: String, _ value: [String: IndianaJSON]) throws -> [String: IndianaJSON] {
28
+ var value = value
29
+ func require(_ allowed: Bool) throws { if !allowed { throw IndianaClientError.invalidConfiguration } }
30
+ if operation == "previewAttach" {
31
+ let plans = array(value["plans"]), provider = value["provider"]?.string
32
+ try require(Set(plans.compactMap { $0.object?["productVersionId"]?.string }).count == plans.count)
33
+ try require(plans.allSatisfy { ($0.object?["featureQuantities"]?.object?.count ?? 0) <= 100 })
34
+ try require(value["offerId"] == nil || value["billingSubscriptionId"] == nil && (provider == "stripe" || value["nativeOffer"] != nil))
35
+ try require(value["nativeOffer"] == nil || provider != "stripe" && plans.count == 1)
36
+ try require(value["newBillingSubscription"] == nil || provider == "stripe" && value["billingSubscriptionId"] == nil)
37
+ try require(value["invoiceMode"]?.object?["enabled"] != .bool(true) || provider == "stripe" && value["entityId"] == nil)
38
+ if let offer = value["nativeOffer"]?.object {
39
+ try require(offer["offerKind"] != .string("none") || offer["offerId"] == .null && offer["discountMinor"] == .number(0) && offer["trialDays"] == .null)
40
+ try require(offer["trialDays"] == .null || offer["discountMinor"] == .number(0))
41
+ }
42
+ }
43
+ if operation == "previewUpdate" {
44
+ let type = value["change"]?.object?["type"]?.string, timing = value["timing"]?.string
45
+ try require(timing != "store" || type == "plan")
46
+ try require(type != "billing_cycle" || timing == (value["change"]?.object?["billingCycleAnchor"] == .string("now") ? "immediate" : "renewal"))
47
+ if let group = value["membershipIds"] {
48
+ let members = array(group)
49
+ try require(["cancel", "uncancel"].contains(type ?? "") && members.contains(value["membershipId"] ?? .null) && Set(members.compactMap(\.string)).count == members.count)
50
+ }
51
+ try require(value["carryOverBalances"] == nil && value["carryOverUsages"] == nil || type == "plan" && timing == "immediate")
52
+ try require(value["prorationBehavior"] == nil || type == "quantity")
53
+ for field in ["carryOverBalances", "carryOverUsages"] {
54
+ if let ids = value[field]?.object?["featureIds"] { let list = array(ids); try require(Set(list.compactMap(\.string)).count == list.count) }
55
+ }
56
+ }
57
+ if operation == "setBillingCoupons" { let ids = array(value["grantIds"]); try require(Set(ids.compactMap(\.string)).count == ids.count) }
58
+ if operation == "prepareBillingAuthorization", value["kind"] == .string("auto_topup") {
59
+ try require(decimal(value["quantity"]) >= 1)
60
+ value["quantity"] = .string(quantity(value["quantity"]!.string!))
61
+ value["threshold"] = .string(quantity(value["threshold"]!.string!))
62
+ }
63
+ if ["checkEntitlement", "resolvePaywall", "resolveOffering"].contains(operation), var requirement = value["requirement"]?.object {
64
+ try require(decimal(requirement["quantity"]) > 0)
65
+ requirement["quantity"] = .string(quantity(requirement["quantity"]!.string!))
66
+ requirement["properties"] = .object(try properties(requirement["properties"]!.object!))
67
+ value["requirement"] = .object(requirement)
68
+ }
69
+ if operation == "quoteBalance", var command = value["command"]?.object {
70
+ try require(decimal(command["quantity"]) > 0)
71
+ command["quantity"] = .string(quantity(command["quantity"]!.string!))
72
+ command["properties"] = .object(try properties(command["properties"]!.object!)); value["command"] = .object(command)
73
+ }
74
+ if operation == "readLedger", let query = value["query"]?.object, let from = query["from"]?.string, let to = query["to"]?.string { try require(from <= to) }
75
+ if operation == "readCreditStatement" {
76
+ guard let from = IndianaDates.parse(value["from"]!.string!), let to = IndianaDates.parse(value["to"]!.string!) else { throw IndianaClientError.invalidConfiguration }
77
+ try require(to > from && to.timeIntervalSince(from) <= 366 * 86400)
78
+ try require(value["after"] == .null || value["snapshotAt"] != .null)
79
+ }
80
+ return value
81
+ }
82
+ static func response(_ operation: String, _ value: IndianaJSON) throws {
83
+ func require(_ allowed: Bool) throws { if !allowed { throw IndianaClientError.invalidResponse } }
84
+ let checks = [
85
+ value.object?["check"]?.object,
86
+ operation == "resolvePaywall" ? value.object?["paywall"]?.object?["check"]?.object : nil,
87
+ ].compactMap { $0 }
88
+ for check in checks {
89
+ try require(check["allowed"] == .bool(check["reason"] == .null))
90
+ try require(check["allowed"] != .bool(true) || check["preview"] == .null)
91
+ let candidates = array(check["preview"]?.object?["candidates"])
92
+ try require(Set(candidates.compactMap { $0.object?["productVersionId"]?.string }).count == candidates.count)
93
+ try require(candidates.allSatisfy { ($0.object?["action"] == .string("upgrade")) == ($0.object?["membershipId"] != .null) })
94
+ }
95
+ if operation == "quoteBalance", let quote = value.object?["quote"]?.object {
96
+ let allocations = array(quote["allocations"])
97
+ try require(quote["allowed"] == .bool(true) ? quote["reason"] == .null : quote["reason"] != .null && allocations.isEmpty)
98
+ if quote["overageBehavior"] == nil { try require(quote["requestedQuantity"] == nil && decimal(quote["quantity"]) > 0) }
99
+ else { try require(quote["requestedQuantity"] != nil && decimal(quote["requestedQuantity"]) >= decimal(quote["quantity"]) && (quote["overageBehavior"] == .string("cap") || quote["requestedQuantity"] == quote["quantity"])) }
100
+ }
101
+ }
102
+ private static func array(_ value: IndianaJSON?) -> [IndianaJSON] { if case .array(let values)? = value { return values }; return [] }
103
+ private static func decimal(_ value: IndianaJSON?) -> Decimal { Decimal(string: value?.string ?? "0", locale: Locale(identifier: "en_US_POSIX")) ?? 0 }
104
+ private static func quantity(_ value: String) -> String {
105
+ var value = value
106
+ if value.contains(".") { while value.hasSuffix("0") { value.removeLast() }; if value.hasSuffix(".") { value.removeLast() } }; return value == "-0" ? "0" : value
107
+ }
108
+ private static func properties(_ value: [String: IndianaJSON]) throws -> [String: IndianaJSON] {
109
+ guard value.count <= 16 else { throw IndianaClientError.invalidConfiguration }
110
+ return try value.mapValues { item in
111
+ switch item {
112
+ case .string: return item
113
+ case .bool(let flag): return .string(flag ? "true" : "false")
114
+ case .number(let number):
115
+ if number == 0 { return .string("0") }
116
+ let encoded = String(data: try JSONEncoder().encode(item), encoding: .utf8)!.lowercased()
117
+ let parts = encoded.split(separator: "e").map(String.init)
118
+ if parts.count == 1 { return .string(encoded) }
119
+ let exponent = Int(parts[1])!, mantissa = parts[0]
120
+ if abs(number) >= 1e-6 && abs(number) < 1e21 {
121
+ let negative = mantissa.hasPrefix("-"), unsigned = negative ? String(mantissa.dropFirst()) : mantissa
122
+ let digits = unsigned.replacingOccurrences(of: ".", with: ""), point = (unsigned.firstIndex(of: ".").map { unsigned.distance(from: unsigned.startIndex, to: $0) } ?? unsigned.count) + exponent
123
+ let expanded = point <= 0 ? "0." + String(repeating: "0", count: -point) + digits : point >= digits.count ? digits + String(repeating: "0", count: point - digits.count) : String(digits.prefix(point)) + "." + String(digits.dropFirst(point))
124
+ return .string((negative ? "-" : "") + expanded)
125
+ }
126
+ return .string(mantissa + "e" + (exponent >= 0 ? "+" : "") + String(exponent))
127
+ default: throw IndianaClientError.invalidConfiguration
128
+ }
129
+ }
130
+ }
131
+ private static func jsonBudget(_ value: IndianaJSON) throws {
132
+ var count = 0
133
+ func visit(_ value: IndianaJSON, _ depth: Int) throws {
134
+ count += 1; guard count <= 10000, depth <= 20 else { throw IndianaClientError.invalidConfiguration }
135
+ switch value {
136
+ case .string(let text): guard !text.contains("\0") else { throw IndianaClientError.invalidConfiguration }
137
+ case .array(let values): for child in values { try visit(child, depth + 1) }
138
+ case .object(let object): for (key, child) in object { guard !key.contains("\0"), !["__proto__", "constructor", "prototype"].contains(key) else { throw IndianaClientError.invalidConfiguration }; try visit(child, depth + 1) }
139
+ default: break
140
+ }
141
+ }
142
+ try visit(value, 0); let encoder = JSONEncoder(); encoder.outputFormatting = [.withoutEscapingSlashes]
143
+ guard try encoder.encode(value).count <= 65536 else { throw IndianaClientError.invalidConfiguration }
144
+ }
145
+ }
@@ -0,0 +1,129 @@
1
+ import Foundation
2
+
3
+ /// Opaque credentials issued by the merchant backend; keep them in memory.
4
+ public struct IndianaCustomerSession: Codable, Sendable {
5
+ public let customerId: UUID
6
+ public let sessionId: UUID
7
+ public let accessToken: String
8
+ public let refreshToken: String
9
+ public let expiresAt: String
10
+ public let refreshExpiresAt: String
11
+ public init(customerId: UUID, sessionId: UUID, accessToken: String, refreshToken: String, expiresAt: String, refreshExpiresAt: String) {
12
+ self.customerId = customerId; self.sessionId = sessionId; self.accessToken = accessToken; self.refreshToken = refreshToken; self.expiresAt = expiresAt; self.refreshExpiresAt = refreshExpiresAt
13
+ }
14
+ internal func deadline(_ value: String) -> Date? {
15
+ let parser = ISO8601DateFormatter(); parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
16
+ return parser.date(from: value) ?? ISO8601DateFormatter().date(from: value)
17
+ }
18
+ internal func validate(fresh: Bool = false) throws {
19
+ guard accessToken.range(of: "^gg_ct_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil,
20
+ refreshToken.range(of: "^gg_rt_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil,
21
+ let access = deadline(expiresAt), let refresh = deadline(refreshExpiresAt), refresh >= access, refresh > Date(), !fresh || access > Date()
22
+ else { throw IndianaClientError.invalidResponse }
23
+ }
24
+ }
25
+
26
+ /// All child capabilities share this owner; rotation never changes their identity epoch.
27
+ internal actor IndianaSessionTransport: IndianaHTTP {
28
+ private let upstream: any IndianaHTTP
29
+ private let scope: IndianaScope
30
+ private let now: @Sendable () -> Date
31
+ private var credentials: IndianaCustomerSession?
32
+ private var originalAccess: String?
33
+ private var epoch = UUID()
34
+ private var identityRevision: UInt64 = 0
35
+ private var onInvalidated: (@Sendable () async -> Void)?
36
+ private var pending: (UUID, Task<IndianaCustomerSession, Error>)?
37
+ private var accessProvider: (@Sendable () async throws -> String)?
38
+ func setAccessTokenProvider(_ provider: (@Sendable () async throws -> String)?) { accessProvider = provider }
39
+ init(_ upstream: any IndianaHTTP, scope: IndianaScope, now: @escaping @Sendable () -> Date = { Date() }) { self.upstream = upstream; self.scope = scope; self.now = now }
40
+ func identify(_ access: String?, revision: UInt64? = nil, onInvalidated: (@Sendable () async -> Void)? = nil) {
41
+ if let revision { guard revision >= identityRevision else { return }; identityRevision = revision } else { identityRevision += 1 }
42
+ epoch = UUID(); credentials = nil; originalAccess = access; pending = nil; self.onInvalidated = onInvalidated
43
+ }
44
+ func identify(_ value: IndianaCustomerSession, revision: UInt64? = nil, onInvalidated: (@Sendable () async -> Void)? = nil) throws {
45
+ try value.validate(); if let revision, revision < identityRevision { return }
46
+ identify(value.accessToken, revision: revision, onInvalidated: onInvalidated); credentials = value
47
+ }
48
+ private func invalidate() async {
49
+ let notify = onInvalidated
50
+ epoch = UUID(); credentials = nil; originalAccess = nil; pending = nil; onInvalidated = nil
51
+ await notify?()
52
+ }
53
+ private func awaitRefresh(_ task: Task<IndianaCustomerSession, Error>, epoch version: UUID, revision: UInt64) async throws -> IndianaCustomerSession {
54
+ do {
55
+ let next = try await task.value
56
+ guard version == epoch else { throw IndianaClientError.identityChanged }
57
+ return next
58
+ } catch {
59
+ guard revision == identityRevision else { throw IndianaClientError.identityChanged }
60
+ throw error
61
+ }
62
+ }
63
+ func refresh() async throws -> IndianaCustomerSession {
64
+ guard accessProvider == nil else { throw IndianaClientError.unsupportedCapability }
65
+ if let pending {
66
+ return try await awaitRefresh(pending.1, epoch: epoch, revision: identityRevision)
67
+ }
68
+ guard let current = credentials else { throw IndianaClientError.unauthenticated }
69
+ guard let end = current.deadline(current.refreshExpiresAt), end > now() else {
70
+ await invalidate(); throw IndianaClientError.unauthenticated
71
+ }
72
+ let version = epoch, revision = identityRevision, job = UUID(), upstream = upstream, scope = scope
73
+ let task = Task { [self] () async throws -> IndianaCustomerSession in
74
+ do {
75
+ let data = try await upstream.send(path: "customer-sessions/refresh", method: "POST", authorization: current.refreshToken, body: Data("{}".utf8))
76
+ guard version == epoch else { throw IndianaClientError.identityChanged }
77
+ struct Envelope: Decodable { let scope: IndianaScope }
78
+ guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data), envelope.scope == scope else { throw IndianaClientError.invalidResponse }
79
+ let next = try JSONDecoder().decode(IndianaCustomerSession.self, from: data); try next.validate(fresh: true)
80
+ guard next.customerId == current.customerId, next.sessionId == current.sessionId else { throw IndianaClientError.invalidResponse }
81
+ credentials = next
82
+ if pending?.0 == job { pending = nil }
83
+ return next
84
+ } catch {
85
+ guard version == epoch else { throw IndianaClientError.identityChanged }
86
+ if case IndianaClientError.requestFailed(401) = error { await invalidate() }
87
+ if pending?.0 == job { pending = nil }
88
+ throw error
89
+ }
90
+ }
91
+ pending = (job, task)
92
+ return try await awaitRefresh(task, epoch: version, revision: revision)
93
+ }
94
+ func access() async throws -> String {
95
+ let version = epoch
96
+ if let accessProvider {
97
+ guard originalAccess != nil else { throw IndianaClientError.unauthenticated }
98
+ let token = try await accessProvider()
99
+ guard version == epoch else { throw IndianaClientError.identityChanged }
100
+ guard token.range(of: "^gg_ct_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { throw IndianaClientError.invalidResponse }
101
+ return token
102
+ }
103
+ if let current = credentials, (current.deadline(current.expiresAt) ?? .distantPast) <= now().addingTimeInterval(30) { _ = try await refresh() }
104
+ guard version == epoch else { throw IndianaClientError.identityChanged }
105
+ guard let token = credentials?.accessToken ?? originalAccess else { throw IndianaClientError.unauthenticated }; return token
106
+ }
107
+ func send(path: String, method: String, authorization: String, body: Data?) async throws -> Data {
108
+ let version = epoch
109
+ var payload = body.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
110
+ let proof = payload?["customerToken"] as? String
111
+ if path.hasPrefix("push/"), payload?["credentialDigest"] != nil {
112
+ guard originalAccess != nil else { throw IndianaClientError.unauthenticated }
113
+ let result = try await upstream.send(path: path, method: method, authorization: authorization, body: body)
114
+ guard version == epoch else { throw IndianaClientError.identityChanged }; return result
115
+ }
116
+ if originalAccess == nil && (authorization.hasPrefix("gg_ct_") || (proof?.hasPrefix("gg_ct_") ?? false)) { throw IndianaClientError.unauthenticated }
117
+ let needsCustomer = originalAccess != nil && (authorization == originalAccess || proof == originalAccess)
118
+ var token = authorization
119
+ if needsCustomer {
120
+ let active = try await access()
121
+ guard version == epoch else { throw IndianaClientError.identityChanged }
122
+ if authorization == originalAccess { token = active }
123
+ if proof == originalAccess { payload?["customerToken"] = active }
124
+ }
125
+ let outgoing = proof == originalAccess && needsCustomer ? try payload.map { try JSONSerialization.data(withJSONObject: $0) } : body
126
+ let result = try await upstream.send(path: path, method: method, authorization: token, body: outgoing)
127
+ guard !needsCustomer || version == epoch else { throw IndianaClientError.identityChanged }; return result
128
+ }
129
+ }
@@ -0,0 +1,86 @@
1
+ import Foundation
2
+
3
+ public struct IndianaFeatureResult: Codable, Sendable {
4
+ public let value: IndianaJSON
5
+ public let source: String
6
+ public let on: Bool
7
+ public let off: Bool
8
+ public let ruleId: String
9
+ public let experimentResult: IndianaJSON?
10
+ }
11
+
12
+ #if canImport(UIKit)
13
+ import UIKit
14
+
15
+ /// Local and private/remote Feature evaluation share the SDK's packaged GB engine.
16
+ /// The hidden shell receives no Customer Token; HTTP and identity remain native.
17
+ @MainActor public final class IndianaFeatureClient {
18
+ private let configuration: IndianaConfiguration
19
+ private let attributes: [String: IndianaJSON]
20
+ private let operation: @Sendable (IndianaCustomerOperation, [String: IndianaJSON]) async throws -> IndianaJSON
21
+ private var host: IndianaBundledWebView!
22
+ public var onError: (@MainActor (Error) -> Void)?
23
+ private var ready = false
24
+ private var stopped = false
25
+ private var readiness: [CheckedContinuation<Void, Error>] = []
26
+ private var pending: [UUID: CheckedContinuation<IndianaJSON, Error>] = [:]
27
+ init(configuration: IndianaConfiguration, attributes: [String: IndianaJSON], operation: @escaping @Sendable (IndianaCustomerOperation, [String: IndianaJSON]) async throws -> IndianaJSON) {
28
+ self.configuration = configuration; self.attributes = attributes; self.operation = operation
29
+ host = IndianaBundledWebView(label: "Indiana Feature evaluator", dispatch: { [weak self] method, payload in
30
+ guard let self else { throw IndianaClientError.identityChanged }; return try await self.dispatch(method, payload)
31
+ }, onError: { [weak self] error in guard let self else { return }; if !self.ready { self.close() } else { self.onError?(error) } })
32
+ host.start()
33
+ Task { [weak self] in try? await Task.sleep(nanoseconds: 45_000_000_000); if let self, !self.ready { self.close() } }
34
+ }
35
+ public func evalFeature(_ key: String) async throws -> IndianaFeatureResult {
36
+ let value = try await call(["method": .string("evalFeature"), "key": .string(key)])
37
+ let data = try JSONEncoder().encode(value)
38
+ try IndianaSchema.validate("nativeFeatureResult", data: data)
39
+ return try JSONDecoder().decode(IndianaFeatureResult.self, from: data)
40
+ }
41
+ public func isOn(_ key: String) async throws -> Bool { try await evalFeature(key).on }
42
+ public func isOff(_ key: String) async throws -> Bool { try await evalFeature(key).off }
43
+ public func getFeatureValue(_ key: String, fallback: IndianaJSON = .null) async throws -> IndianaJSON { try await call(["method": .string("getFeatureValue"), "key": .string(key), "fallback": fallback]) }
44
+ public func setAttributes(_ value: [String: IndianaJSON]) async throws { _ = try await call(["method": .string("setAttributes"), "attributes": .object(value)]) }
45
+ public func refresh() async throws { _ = try await call(["method": .string("refresh")]) }
46
+ public func flush() async throws { _ = try await call(["method": .string("flush")]) }
47
+ public func close() {
48
+ guard !stopped else { return }; stopped = true; host?.destroy()
49
+ let waiting = readiness; readiness = []; for continuation in waiting { continuation.resume(throwing: IndianaClientError.identityChanged) }
50
+ let calls = pending; pending = [:]; for continuation in calls.values { continuation.resume(throwing: IndianaClientError.identityChanged) }
51
+ }
52
+ private func call(_ command: [String: IndianaJSON]) async throws -> IndianaJSON {
53
+ guard !stopped else { throw IndianaClientError.identityChanged }
54
+ if !ready { try await withCheckedThrowingContinuation { readiness.append($0) } }
55
+ guard !stopped else { throw IndianaClientError.identityChanged }
56
+ let id = UUID()
57
+ return try await withCheckedThrowingContinuation { continuation in
58
+ pending[id] = continuation
59
+ host.send(.object(["event": .string("feature_call"), "id": .string(id.uuidString.lowercased()), "input": .object(command)]))
60
+ Task { [weak self] in
61
+ try? await Task.sleep(nanoseconds: 45_000_000_000)
62
+ self?.pending.removeValue(forKey: id)?.resume(throwing: IndianaClientError.temporaryUnavailable)
63
+ }
64
+ }
65
+ }
66
+ private func dispatch(_ method: String, _ value: IndianaJSON) async throws -> IndianaJSON {
67
+ switch method {
68
+ case "ready":
69
+ host.send(.object(["event": .string("init"), "surface": .string("features"), "scope": .object(["projectId": .string(configuration.scope.projectId.uuidString.lowercased()), "environment": .string(configuration.scope.environment.rawValue)]), "apiOrigin": .string(configuration.apiOrigin), "attributes": .object(attributes)])); return .null
70
+ case "featureOperation":
71
+ guard !stopped, let name = value.object?["operation"]?.string,
72
+ ["getFeatures", "evaluateFeatures", "trackEvents"].contains(name), let selected = IndianaCustomerOperation(rawValue: name),
73
+ let input = value.object?["input"]?.object else { throw IndianaFunnelError.invalidCommand }
74
+ return try await operation(selected, input)
75
+ case "featuresReady":
76
+ guard !stopped else { throw IndianaClientError.identityChanged }; ready = true
77
+ let waiting = readiness; readiness = []; for continuation in waiting { continuation.resume() }; return .null
78
+ case "featureResult":
79
+ guard !stopped, let id = value.object?["id"]?.string.flatMap(UUID.init(uuidString:)), let continuation = pending.removeValue(forKey: id) else { return .null }
80
+ if value.object?["error"] != nil { continuation.resume(throwing: IndianaClientError.invalidResponse) }
81
+ else { continuation.resume(returning: value.object?["result"] ?? .null) }; return .null
82
+ default: throw IndianaFunnelError.invalidCommand
83
+ }
84
+ }
85
+ }
86
+ #endif