@tinkerlandai/indiana-react-native 0.9.1 → 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.
- package/IndianaReactNative.podspec +19 -0
- package/README.md +61 -4
- package/THIRD_PARTY_NOTICES.md +74 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgeModule.kt +237 -0
- package/android/src/main/java/ai/tinkerland/indiana/reactnative/IndianaBridgePackage.kt +11 -0
- package/dist/index.cjs +17 -1
- package/dist/index.d.cts +57408 -604
- package/dist/index.d.ts +57408 -604
- package/dist/index.js +17 -1
- package/ios/IndianaBridge.m +11 -0
- package/ios/IndianaBridge.swift +275 -0
- package/native-sdk/android/assets/indiana/index.html +268 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BillingAuthorizationTerms.kt +55 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/BundledWebView.kt +90 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentSession.kt +45 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/ContentView.kt +36 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerOperations.generated.kt +84 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerResponseSemantics.kt +193 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSemantics.kt +120 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/CustomerSession.kt +106 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FeatureClient.kt +65 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelCommandStorage.kt +163 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelConfigurationRelay.kt +50 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelSession.kt +208 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/FunnelView.kt +71 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GeneratedProtocol.kt +16 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/GoogleStorefront.kt +165 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/HTTPTransport.kt +43 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/IndianaClient.kt +526 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativePendingKeystore.kt +81 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeReceiptRelay.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/NativeStoreOffer.kt +24 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallContent.kt +75 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallSession.kt +144 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PaywallView.kt +74 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/Protocol.kt +28 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/PushInstallation.kt +170 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/RendererNavigation.kt +13 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/SDKVersion.kt +6 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/StoreSubscriptionManagement.kt +19 -0
- package/native-sdk/android/java/ai/tinkerland/indiana/WireSchema.kt +76 -0
- package/native-sdk/android/resources/funnel-wire-schemas.json +1 -0
- package/native-sdk/android/resources/indiana-third-party-notices.txt +101 -0
- package/native-sdk/android/resources/renderer-data-url.txt +1 -0
- package/native-sdk/ios/AnalyticsReceipt.swift +10 -0
- package/native-sdk/ios/AppleStorefront.swift +218 -0
- package/native-sdk/ios/BillingAuthorizationTerms.swift +57 -0
- package/native-sdk/ios/BundledWebView.swift +87 -0
- package/native-sdk/ios/ContentSession.swift +48 -0
- package/native-sdk/ios/ContentView.swift +51 -0
- package/native-sdk/ios/CustomerOperations.generated.swift +166 -0
- package/native-sdk/ios/CustomerResponseSemantics.swift +232 -0
- package/native-sdk/ios/CustomerSemantics.swift +145 -0
- package/native-sdk/ios/CustomerSession.swift +129 -0
- package/native-sdk/ios/FeatureClient.swift +86 -0
- package/native-sdk/ios/FunnelCommandStorage.swift +163 -0
- package/native-sdk/ios/FunnelConfigurationRelay.swift +48 -0
- package/native-sdk/ios/FunnelSession.swift +232 -0
- package/native-sdk/ios/FunnelView.swift +125 -0
- package/native-sdk/ios/GeneratedProtocol.swift +16 -0
- package/native-sdk/ios/HTTPTransport.swift +74 -0
- package/native-sdk/ios/IndianaClient.swift +601 -0
- package/native-sdk/ios/NativePendingKeychain.swift +52 -0
- package/native-sdk/ios/NativeReceiptRelay.swift +28 -0
- package/native-sdk/ios/NativeStoreOffer.swift +34 -0
- package/native-sdk/ios/PaywallContent.swift +75 -0
- package/native-sdk/ios/PaywallSession.swift +124 -0
- package/native-sdk/ios/PaywallView.swift +87 -0
- package/native-sdk/ios/Protocol.swift +49 -0
- package/native-sdk/ios/PushInstallation.swift +154 -0
- package/native-sdk/ios/RendererNavigation.swift +8 -0
- package/native-sdk/ios/Resources.swift +15 -0
- package/native-sdk/ios/SDKVersion.swift +4 -0
- package/native-sdk/ios/StoreSubscriptionManagement.swift +11 -0
- package/native-sdk/ios/WebView/THIRD_PARTY_NOTICES.txt +101 -0
- package/native-sdk/ios/WebView/index.html +268 -0
- package/native-sdk/ios/WebView/renderer-data-url.txt +1 -0
- package/native-sdk/ios/WireSchema.swift +107 -0
- package/native-sdk/ios/funnel-wire-schemas.json +1 -0
- package/package.json +8 -3
- 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
|