strata-storage 2.4.3 → 2.6.0

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 (114) hide show
  1. package/AI-INTEGRATION-GUIDE.md +115 -261
  2. package/README.md +426 -182
  3. package/android/AGENTS.md +51 -0
  4. package/android/CLAUDE.md +89 -0
  5. package/android/build.gradle +1 -1
  6. package/android/src/main/java/com/strata/storage/FilesystemStorage.java +287 -0
  7. package/android/src/main/java/com/strata/storage/SQLiteStorage.java +260 -203
  8. package/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +357 -69
  9. package/dist/README.md +426 -182
  10. package/dist/adapters/capacitor/FilesystemAdapter.d.ts.map +1 -1
  11. package/dist/adapters/capacitor/FilesystemAdapter.js +2 -1
  12. package/dist/adapters/capacitor/PreferencesAdapter.d.ts.map +1 -1
  13. package/dist/adapters/capacitor/PreferencesAdapter.js +2 -1
  14. package/dist/adapters/capacitor/SecureAdapter.d.ts.map +1 -1
  15. package/dist/adapters/capacitor/SecureAdapter.js +2 -1
  16. package/dist/adapters/capacitor/SqliteAdapter.d.ts.map +1 -1
  17. package/dist/adapters/capacitor/SqliteAdapter.js +2 -1
  18. package/dist/adapters/web/CacheAdapter.d.ts.map +1 -1
  19. package/dist/adapters/web/CacheAdapter.js +11 -3
  20. package/dist/adapters/web/CookieAdapter.d.ts +37 -1
  21. package/dist/adapters/web/CookieAdapter.d.ts.map +1 -1
  22. package/dist/adapters/web/CookieAdapter.js +89 -9
  23. package/dist/adapters/web/IndexedDBAdapter.d.ts.map +1 -1
  24. package/dist/adapters/web/IndexedDBAdapter.js +10 -2
  25. package/dist/adapters/web/LocalStorageAdapter.d.ts +31 -0
  26. package/dist/adapters/web/LocalStorageAdapter.d.ts.map +1 -1
  27. package/dist/adapters/web/LocalStorageAdapter.js +92 -19
  28. package/dist/adapters/web/MemoryAdapter.d.ts +24 -0
  29. package/dist/adapters/web/MemoryAdapter.d.ts.map +1 -1
  30. package/dist/adapters/web/MemoryAdapter.js +69 -18
  31. package/dist/adapters/web/SessionStorageAdapter.d.ts +24 -0
  32. package/dist/adapters/web/SessionStorageAdapter.d.ts.map +1 -1
  33. package/dist/adapters/web/SessionStorageAdapter.js +71 -9
  34. package/dist/adapters/web/URLAdapter.d.ts +59 -0
  35. package/dist/adapters/web/URLAdapter.d.ts.map +1 -0
  36. package/dist/adapters/web/URLAdapter.js +234 -0
  37. package/dist/adapters/web/index.d.ts +1 -0
  38. package/dist/adapters/web/index.d.ts.map +1 -1
  39. package/dist/adapters/web/index.js +1 -0
  40. package/dist/android/AGENTS.md +51 -0
  41. package/dist/android/CLAUDE.md +89 -0
  42. package/dist/android/build.gradle +1 -1
  43. package/dist/android/src/main/java/com/strata/storage/FilesystemStorage.java +287 -0
  44. package/dist/android/src/main/java/com/strata/storage/SQLiteStorage.java +260 -203
  45. package/dist/android/src/main/java/com/stratastorage/StrataStoragePlugin.java +357 -69
  46. package/dist/capacitor.d.ts.map +1 -1
  47. package/dist/capacitor.js +2 -1
  48. package/dist/core/BaseAdapter.d.ts +8 -0
  49. package/dist/core/BaseAdapter.d.ts.map +1 -1
  50. package/dist/core/BaseAdapter.js +34 -14
  51. package/dist/core/Strata.d.ts +56 -2
  52. package/dist/core/Strata.d.ts.map +1 -1
  53. package/dist/core/Strata.js +501 -53
  54. package/dist/features/encryption.d.ts.map +1 -1
  55. package/dist/features/encryption.js +3 -2
  56. package/dist/features/integrity.d.ts +16 -0
  57. package/dist/features/integrity.d.ts.map +1 -0
  58. package/dist/features/integrity.js +28 -0
  59. package/dist/features/observer.d.ts.map +1 -1
  60. package/dist/features/observer.js +2 -1
  61. package/dist/features/query.d.ts +7 -1
  62. package/dist/features/query.d.ts.map +1 -1
  63. package/dist/features/query.js +9 -2
  64. package/dist/features/sync.d.ts.map +1 -1
  65. package/dist/features/sync.js +4 -3
  66. package/dist/index.d.ts +35 -2
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +55 -30
  69. package/dist/integrations/angular/index.d.ts +158 -0
  70. package/dist/integrations/angular/index.d.ts.map +1 -0
  71. package/dist/integrations/angular/index.js +395 -0
  72. package/dist/integrations/index.d.ts +15 -0
  73. package/dist/integrations/index.d.ts.map +1 -0
  74. package/dist/integrations/index.js +18 -0
  75. package/dist/integrations/react/index.d.ts +75 -0
  76. package/dist/integrations/react/index.d.ts.map +1 -0
  77. package/dist/integrations/react/index.js +191 -0
  78. package/dist/integrations/vue/index.d.ts +103 -0
  79. package/dist/integrations/vue/index.d.ts.map +1 -0
  80. package/dist/integrations/vue/index.js +274 -0
  81. package/dist/ios/AGENTS.md +48 -0
  82. package/dist/ios/CLAUDE.md +84 -0
  83. package/dist/ios/Plugin/FilesystemStorage.swift +218 -0
  84. package/dist/ios/Plugin/KeychainStorage.swift +139 -50
  85. package/dist/ios/Plugin/SQLiteStorage.swift +279 -147
  86. package/dist/ios/Plugin/StrataStoragePlugin.m +23 -0
  87. package/dist/ios/Plugin/StrataStoragePlugin.swift +272 -65
  88. package/dist/package.json +21 -5
  89. package/dist/plugin/index.d.ts.map +1 -1
  90. package/dist/plugin/index.js +2 -1
  91. package/dist/types/index.d.ts +58 -9
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/dist/types/index.js +0 -13
  94. package/dist/utils/errors.d.ts +7 -0
  95. package/dist/utils/errors.d.ts.map +1 -1
  96. package/dist/utils/errors.js +15 -3
  97. package/dist/utils/index.d.ts +63 -5
  98. package/dist/utils/index.d.ts.map +1 -1
  99. package/dist/utils/index.js +109 -16
  100. package/dist/utils/logger.d.ts +31 -0
  101. package/dist/utils/logger.d.ts.map +1 -0
  102. package/dist/utils/logger.js +63 -0
  103. package/ios/AGENTS.md +48 -0
  104. package/ios/CLAUDE.md +84 -0
  105. package/ios/Plugin/FilesystemStorage.swift +218 -0
  106. package/ios/Plugin/KeychainStorage.swift +139 -50
  107. package/ios/Plugin/SQLiteStorage.swift +279 -147
  108. package/ios/Plugin/StrataStoragePlugin.m +23 -0
  109. package/ios/Plugin/StrataStoragePlugin.swift +272 -65
  110. package/package.json +31 -20
  111. package/scripts/build.js +16 -5
  112. package/scripts/configure.js +2 -6
  113. package/scripts/postinstall.js +2 -2
  114. package/Readme.md +0 -271
package/ios/CLAUDE.md ADDED
@@ -0,0 +1,84 @@
1
+ # CLAUDE.md — ios/
2
+
3
+ Last Updated: 2026-05-27
4
+
5
+ ## iOS Native Plugin
6
+
7
+ Swift implementation of native storage backends for Capacitor.
8
+
9
+ ### Files
10
+
11
+ | File | Purpose |
12
+ |------|---------|
13
+ | `Plugin/StrataStoragePlugin.swift` | Main Capacitor plugin entry point |
14
+ | `Plugin/UserDefaultsStorage.swift` | UserDefaults-based general storage |
15
+ | `Plugin/KeychainStorage.swift` | Keychain-based secure storage |
16
+ | `Plugin/SQLiteStorage.swift` | SQLite database storage (multi-store, v2.6.0) |
17
+ | `Plugin/FilesystemStorage.swift` | File-per-key storage under `NSDocumentsDirectory/strata_storage/` (new in v2.6.0) |
18
+
19
+ ### Configuration
20
+
21
+ - Pod spec: `StrataStorage.podspec` (root)
22
+ - Minimum iOS version: defined in podspec
23
+ - Language: Swift
24
+
25
+ ## v2.6.0 Changes
26
+
27
+ ### SQLite multi-store
28
+ `SQLiteStorage.swift` now accepts `database` and `table` parameters from the
29
+ Capacitor call options. Each unique `(database, table)` pair opens a separate
30
+ `.db` file in the app's documents directory. Table identifiers are sanitised to
31
+ `[A-Za-z0-9_]` before use in SQL. The full `StorageValue` wrapper is serialised
32
+ to JSON and stored in a single `value` column; native `get` deserialises and
33
+ returns the full wrapper so TTL, tags, and metadata survive the round-trip.
34
+ Text/blob binds use `SQLITE_TRANSIENT` (removes a latent use-after-free for
35
+ transient Swift buffers). `size(detailed: true)` returns per-column byte
36
+ breakdown.
37
+
38
+ ### FilesystemStorage.swift (new)
39
+ Stores each key as `NSDocumentsDirectory/strata_storage/<sanitised-key>.json`.
40
+ Writes are atomic: the value is first written to
41
+ `strata_storage/.staging/<key>.tmp`, then renamed into place. Temp files live in
42
+ the staging subdirectory, so they are never exposed by `keys()` and are not
43
+ deleted by a targeted `remove()`. `isAvailable()` returns `true` on device.
44
+ `size(detailed: true)` supported.
45
+
46
+ > **Pending on-device verification** — native code is complete and reviewed; see
47
+ > `docs/guides/platforms/device-verification.md` for the test matrix.
48
+
49
+ ## Rules
50
+
51
+ ### Security (IRON-SOLID)
52
+ - Sensitive data MUST use `KeychainStorage`
53
+ - NEVER store credentials, tokens, or secrets in `UserDefaultsStorage`
54
+ - Keychain items should use appropriate access control flags
55
+
56
+ ### Plugin Architecture
57
+ - All native methods are exposed through `StrataStoragePlugin.swift`
58
+ - Plugin bridges Capacitor JS calls to native Swift implementations
59
+ - Each storage backend is a separate Swift file
60
+ - The `CAP_PLUGIN` macro must list every callable method; missing entries cause
61
+ silent `undefined` returns on the JS side
62
+
63
+ ### SQLite
64
+ - `SQLiteStorage.swift` opens or creates the `.db` file identified by the
65
+ `database` call option (default `strata_storage.db`)
66
+ - Handles table creation, migrations, and CRUD operations
67
+ - Use parameterized queries — NEVER string-interpolate SQL
68
+ - Use `SQLITE_TRANSIENT` for text/blob binds
69
+
70
+ ### Filesystem
71
+ - Key strings are sanitised before use as filenames (`/` → `_`, etc.)
72
+ - Staging renames ensure atomic writes; never read from `.staging/`
73
+ - `keys()` lists `.json` files only, ignoring staging artifacts
74
+
75
+ ### Testing
76
+ - Native code is tested through the Capacitor bridge
77
+ - Test via the demo app on iOS simulator following
78
+ `docs/guides/platforms/device-verification.md`
79
+ - Verify all adapter methods work end-to-end
80
+
81
+ ### Before Modifying
82
+ - Understand the Capacitor plugin protocol
83
+ - Test on iOS simulator after changes
84
+ - Verify podspec still resolves
@@ -0,0 +1,218 @@
1
+ import Foundation
2
+ import os.log
3
+
4
+ /**
5
+ * Filesystem-backed storage: one file per key under
6
+ * `<Documents>/strata_storage/`.
7
+ *
8
+ * Value-shape contract (matches src/plugin/definitions.ts, same as SQLite):
9
+ * - File contents are the JSON-serialized FULL `StorageValue` wrapper
10
+ * `{ value, created, updated, expires?, tags?, metadata?, ... }`.
11
+ * - `get` parses the bytes back into the wrapper object; the plugin resolves
12
+ * `{ value: wrapper }`. Missing file OR non-object bytes → miss (no crash).
13
+ *
14
+ * File naming: the storage key is percent-encoded with an allowed set that
15
+ * EXCLUDES `/` (and `%`), so the encoding is reversible and never escapes the
16
+ * storage directory. `decodeFileName` reverses it for `keys()`.
17
+ *
18
+ * Durability: writes go to a temp file in the same directory and are then
19
+ * atomically replaced into place, so a crash mid-write cannot leave a torn
20
+ * file at the real path.
21
+ */
22
+ @objc public class FilesystemStorage: NSObject {
23
+ private let directory: URL
24
+ private let stagingDirectory: URL
25
+ private let logger = OSLog(subsystem: "com.strata.storage", category: "FilesystemStorage")
26
+
27
+ /// Reserved staging subdirectory name (inside the storage dir) holding
28
+ /// in-flight temp files for atomic writes. Enumeration skips it by name, so
29
+ /// a temp file can never collide with an encoded key file (e.g. a real key
30
+ /// literally named ".tmp-x").
31
+ private static let stagingDirName = ".strata-staging"
32
+
33
+ /// Characters allowed verbatim in an on-disk file name. Anything else
34
+ /// (notably `/` and `%`) is percent-escaped, keeping the name reversible
35
+ /// and confined to a single path component.
36
+ private static let fileNameAllowed: CharacterSet = {
37
+ var set = CharacterSet.alphanumerics
38
+ set.insert(charactersIn: "-_. ")
39
+ return set
40
+ }()
41
+
42
+ @objc public override init() {
43
+ let base = (try? FileManager.default.url(
44
+ for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
45
+ )) ?? URL(fileURLWithPath: NSTemporaryDirectory())
46
+ let storageDir = base.appendingPathComponent("strata_storage", isDirectory: true)
47
+ self.directory = storageDir
48
+ self.stagingDirectory = storageDir.appendingPathComponent(FilesystemStorage.stagingDirName, isDirectory: true)
49
+ super.init()
50
+ ensureDirectory()
51
+ }
52
+
53
+ private func ensureDirectory() {
54
+ for dir in [directory, stagingDirectory] {
55
+ if !FileManager.default.fileExists(atPath: dir.path) {
56
+ do {
57
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
58
+ } catch {
59
+ os_log("Failed to create filesystem storage dir: %{public}@", log: logger, type: .error, error.localizedDescription)
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ /// Reversible encoding of a storage key → safe single-component file name.
66
+ private func encodeFileName(_ key: String) -> String {
67
+ // addingPercentEncoding only returns nil for invalid unichar sequences,
68
+ // which a Swift String key cannot contain; fall back defensively.
69
+ return key.addingPercentEncoding(withAllowedCharacters: FilesystemStorage.fileNameAllowed) ?? key
70
+ }
71
+
72
+ /// Reverses `encodeFileName`. Returns nil if the name cannot be decoded.
73
+ private func decodeFileName(_ name: String) -> String? {
74
+ return name.removingPercentEncoding
75
+ }
76
+
77
+ private func fileURL(forKey key: String) -> URL {
78
+ return directory.appendingPathComponent(encodeFileName(key), isDirectory: false)
79
+ }
80
+
81
+ /**
82
+ * Persist the full `StorageValue` wrapper for `key`. The wrapper is
83
+ * JSON-encoded and written atomically (temp file + replace).
84
+ */
85
+ @objc public func set(key: String, wrapper: [String: Any]) throws -> Bool {
86
+ ensureDirectory()
87
+ let data = try JSONSerialization.data(withJSONObject: wrapper, options: [])
88
+ let target = fileURL(forKey: key)
89
+
90
+ // Atomic write: stage to a unique temp file in the reserved staging
91
+ // subdir (same volume → atomic rename), then replace. The staging subdir
92
+ // keeps temp names out of the key namespace entirely.
93
+ let temp = stagingDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false)
94
+ do {
95
+ try data.write(to: temp, options: .atomic)
96
+ if FileManager.default.fileExists(atPath: target.path) {
97
+ _ = try FileManager.default.replaceItemAt(target, withItemAt: temp)
98
+ } else {
99
+ try FileManager.default.moveItem(at: temp, to: target)
100
+ }
101
+ } catch {
102
+ // Clean up the temp file on any failure so we don't leak partials.
103
+ try? FileManager.default.removeItem(at: temp)
104
+ throw error
105
+ }
106
+ return true
107
+ }
108
+
109
+ /**
110
+ * Read the wrapper object for `key`. Missing file OR bytes that are not a
111
+ * JSON object → nil (treated as a miss; never throws on corrupt data).
112
+ */
113
+ @objc public func get(key: String) -> [String: Any]? {
114
+ let url = fileURL(forKey: key)
115
+ guard let data = try? Data(contentsOf: url) else {
116
+ return nil
117
+ }
118
+ guard let wrapper = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
119
+ return nil
120
+ }
121
+ return wrapper
122
+ }
123
+
124
+ @objc public func remove(key: String) -> Bool {
125
+ let url = fileURL(forKey: key)
126
+ if !FileManager.default.fileExists(atPath: url.path) {
127
+ return true // already absent — treat as success/idempotent
128
+ }
129
+ do {
130
+ try FileManager.default.removeItem(at: url)
131
+ return true
132
+ } catch {
133
+ os_log("Failed to remove filesystem key: %{public}@", log: logger, type: .error, error.localizedDescription)
134
+ return false
135
+ }
136
+ }
137
+
138
+ /// Clears stored entries. When `prefix` is set, only keys starting with it
139
+ /// are removed; otherwise the whole storage directory contents are removed.
140
+ @objc public func clear(prefix: String? = nil) -> Bool {
141
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
142
+ var ok = true
143
+ for name in names {
144
+ // Skip the staging subdirectory (never a key).
145
+ if name == FilesystemStorage.stagingDirName { continue }
146
+ guard let key = decodeFileName(name) else { continue }
147
+ if let prefix = prefix, !key.hasPrefix(prefix) { continue }
148
+ do {
149
+ try FileManager.default.removeItem(at: directory.appendingPathComponent(name, isDirectory: false))
150
+ } catch {
151
+ ok = false
152
+ }
153
+ }
154
+ // On a full clear, also drop any orphaned in-flight temp files.
155
+ if prefix == nil, let temps = try? FileManager.default.contentsOfDirectory(atPath: stagingDirectory.path) {
156
+ for temp in temps {
157
+ try? FileManager.default.removeItem(at: stagingDirectory.appendingPathComponent(temp, isDirectory: false))
158
+ }
159
+ }
160
+ return ok
161
+ }
162
+
163
+ /// Lists stored keys, decoding file names back to their original keys.
164
+ /// When `pattern` is set, applies the same prefix/contains matching the
165
+ /// other backends use.
166
+ @objc public func keys(pattern: String? = nil) -> [String] {
167
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
168
+ var keys: [String] = []
169
+ for name in names {
170
+ if name == FilesystemStorage.stagingDirName { continue }
171
+ guard let key = decodeFileName(name) else { continue }
172
+ keys.append(key)
173
+ }
174
+ guard let pattern = pattern else { return keys }
175
+ return keys.filter { $0.hasPrefix(pattern) || $0.contains(pattern) }
176
+ }
177
+
178
+ /**
179
+ * Aggregate sizes over the storage directory.
180
+ *
181
+ * `count` = number of entry files; `values` = sum of file byte sizes;
182
+ * `keys` = sum of decoded-key UTF-8 byte lengths; `metadata` = sum of the
183
+ * serialized `metadata` segment per file (0 when absent or unparseable);
184
+ * `total` = `keys` + `values`.
185
+ */
186
+ @objc public func size() -> [String: Int] {
187
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
188
+ var count = 0
189
+ var keysSize = 0
190
+ var valuesSize = 0
191
+ var metadataSize = 0
192
+
193
+ for name in names {
194
+ if name == FilesystemStorage.stagingDirName { continue }
195
+ guard let key = decodeFileName(name) else { continue }
196
+ let url = directory.appendingPathComponent(name, isDirectory: false)
197
+ count += 1
198
+ keysSize += key.data(using: .utf8)?.count ?? 0
199
+
200
+ if let data = try? Data(contentsOf: url) {
201
+ valuesSize += data.count
202
+ if let wrapper = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
203
+ let metadata = wrapper["metadata"],
204
+ let metadataData = try? JSONSerialization.data(withJSONObject: metadata, options: []) {
205
+ metadataSize += metadataData.count
206
+ }
207
+ }
208
+ }
209
+
210
+ return [
211
+ "count": count,
212
+ "keys": keysSize,
213
+ "values": valuesSize,
214
+ "metadata": metadataSize,
215
+ "total": keysSize + valuesSize
216
+ ]
217
+ }
218
+ }
@@ -1,19 +1,72 @@
1
1
  import Foundation
2
2
  import Security
3
3
 
4
+ /**
5
+ * Keychain-backed secure storage.
6
+ *
7
+ * Security notes:
8
+ * - Items default to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,
9
+ * which keeps data available to background tasks after the first unlock
10
+ * while preventing it from leaving the device via encrypted backups.
11
+ * `kSecAttrAccessibleAlways` / `kSecAttrAccessibleAlwaysThisDeviceOnly`
12
+ * are deprecated and insecure and are intentionally NOT used.
13
+ * - Callers may pass a stricter accessibility class from JS via the
14
+ * `accessible` option (see KeychainAccessible in definitions.ts).
15
+ * - Secret values are never logged.
16
+ */
4
17
  @objc public class KeychainStorage: NSObject {
5
18
  private let service: String
6
19
  private let accessGroup: String?
7
-
20
+ private let defaultAccessible: CFString
21
+
8
22
  @objc public init(service: String? = nil, accessGroup: String? = nil) {
9
23
  self.service = service ?? Bundle.main.bundleIdentifier ?? "StrataStorage"
10
24
  self.accessGroup = accessGroup
25
+ // After-first-unlock + this-device-only is a safe, widely-recommended
26
+ // default for app secrets. It is more permissive than whenUnlocked
27
+ // (so background access works) but still excluded from backups.
28
+ self.defaultAccessible = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
11
29
  super.init()
12
30
  }
13
-
14
- @objc public func set(key: String, value: Any) throws -> Bool {
31
+
32
+ /// Maps a JS `KeychainAccessible` string to the matching Security framework
33
+ /// constant. Unknown / nil values fall back to the secure default.
34
+ private func accessibleConstant(for raw: String?) -> CFString {
35
+ switch raw {
36
+ case "whenUnlocked":
37
+ return kSecAttrAccessibleWhenUnlocked
38
+ case "afterFirstUnlock":
39
+ return kSecAttrAccessibleAfterFirstUnlock
40
+ case "whenUnlockedThisDeviceOnly":
41
+ return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
42
+ case "afterFirstUnlockThisDeviceOnly":
43
+ return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
44
+ case "whenPasscodeSetThisDeviceOnly":
45
+ return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
46
+ default:
47
+ return defaultAccessible
48
+ }
49
+ }
50
+
51
+ /// Builds an NSError that surfaces the OSStatus to the JS bridge without
52
+ /// leaking any stored secret value.
53
+ private func keychainError(_ status: OSStatus, operation: String) -> NSError {
54
+ let message: String
55
+ if #available(iOS 11.3, *), let str = SecCopyErrorMessageString(status, nil) as String? {
56
+ message = str
57
+ } else {
58
+ message = "OSStatus \(status)"
59
+ }
60
+ return NSError(
61
+ domain: "StrataStorage.KeychainStorage",
62
+ code: Int(status),
63
+ userInfo: [NSLocalizedDescriptionKey: "Keychain \(operation) failed: \(message)"]
64
+ )
65
+ }
66
+
67
+ @objc public func set(key: String, value: Any, accessible: String? = nil) throws -> Bool {
15
68
  let data: Data
16
-
69
+
17
70
  if let dataValue = value as? Data {
18
71
  data = dataValue
19
72
  } else if let stringValue = value as? String {
@@ -23,28 +76,43 @@ import Security
23
76
  let jsonData = try JSONSerialization.data(withJSONObject: value, options: [])
24
77
  data = jsonData
25
78
  }
26
-
27
- let query = createQuery(key: key)
28
- SecItemDelete(query as CFDictionary)
29
-
30
- var newItem = query
79
+
80
+ // Remove any existing item first so the accessibility class is applied
81
+ // cleanly (SecItemUpdate cannot change accessibility reliably).
82
+ let deleteQuery = createQuery(key: key)
83
+ SecItemDelete(deleteQuery as CFDictionary)
84
+
85
+ var newItem = createQuery(key: key, accessible: accessibleConstant(for: accessible))
31
86
  newItem[kSecValueData as String] = data
32
-
87
+
33
88
  let status = SecItemAdd(newItem as CFDictionary, nil)
34
- return status == errSecSuccess
89
+ guard status == errSecSuccess else {
90
+ throw keychainError(status, operation: "set")
91
+ }
92
+ return true
93
+ }
94
+
95
+ // Convenience overload kept for existing Objective-C callers.
96
+ @objc public func set(key: String, value: Any) throws -> Bool {
97
+ return try set(key: key, value: value, accessible: nil)
35
98
  }
36
-
99
+
37
100
  @objc public func get(key: String) throws -> Any? {
38
101
  var query = createQuery(key: key)
39
102
  query[kSecReturnData as String] = true
40
103
  query[kSecMatchLimit as String] = kSecMatchLimitOne
41
-
104
+
42
105
  var result: AnyObject?
43
106
  let status = SecItemCopyMatching(query as CFDictionary, &result)
44
-
45
- guard status == errSecSuccess else { return nil }
107
+
108
+ if status == errSecItemNotFound {
109
+ return nil
110
+ }
111
+ guard status == errSecSuccess else {
112
+ throw keychainError(status, operation: "get")
113
+ }
46
114
  guard let data = result as? Data else { return nil }
47
-
115
+
48
116
  // Try to parse as JSON first, fallback to string
49
117
  if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) {
50
118
  return jsonObject
@@ -52,35 +120,41 @@ import Security
52
120
  return String(data: data, encoding: .utf8)
53
121
  }
54
122
  }
55
-
123
+
56
124
  @objc public func remove(key: String) throws -> Bool {
57
125
  let query = createQuery(key: key)
58
126
  let status = SecItemDelete(query as CFDictionary)
59
- return status == errSecSuccess
127
+ guard status == errSecSuccess || status == errSecItemNotFound else {
128
+ throw keychainError(status, operation: "remove")
129
+ }
130
+ return true
60
131
  }
61
-
132
+
62
133
  @objc public func clear(prefix: String? = nil) throws -> Bool {
63
134
  if let prefix = prefix {
64
135
  // Clear only keys with the given prefix
65
136
  let keysToRemove = try keys(pattern: prefix)
66
- var allSuccess = true
67
137
  for key in keysToRemove {
68
- if !(try remove(key: key)) {
69
- allSuccess = false
70
- }
138
+ _ = try remove(key: key)
71
139
  }
72
- return allSuccess
140
+ return true
73
141
  } else {
74
- // Clear all keys
75
- let query: [String: Any] = [
142
+ // Clear all keys for this service (and access group, if any).
143
+ var query: [String: Any] = [
76
144
  kSecClass as String: kSecClassGenericPassword,
77
145
  kSecAttrService as String: service
78
146
  ]
147
+ if let accessGroup = accessGroup {
148
+ query[kSecAttrAccessGroup as String] = accessGroup
149
+ }
79
150
  let status = SecItemDelete(query as CFDictionary)
80
- return status == errSecSuccess || status == errSecItemNotFound
151
+ guard status == errSecSuccess || status == errSecItemNotFound else {
152
+ throw keychainError(status, operation: "clear")
153
+ }
154
+ return true
81
155
  }
82
156
  }
83
-
157
+
84
158
  @objc public func keys(pattern: String? = nil) throws -> [String] {
85
159
  var query: [String: Any] = [
86
160
  kSecClass as String: kSecClassGenericPassword,
@@ -88,57 +162,72 @@ import Security
88
162
  kSecReturnAttributes as String: true,
89
163
  kSecMatchLimit as String: kSecMatchLimitAll
90
164
  ]
91
-
165
+
92
166
  if let accessGroup = accessGroup {
93
167
  query[kSecAttrAccessGroup as String] = accessGroup
94
168
  }
95
-
169
+
96
170
  var result: AnyObject?
97
171
  let status = SecItemCopyMatching(query as CFDictionary, &result)
98
-
172
+
173
+ if status == errSecItemNotFound {
174
+ return []
175
+ }
99
176
  guard status == errSecSuccess,
100
- let items = result as? [[String: Any]] else { return [] }
101
-
177
+ let items = result as? [[String: Any]] else {
178
+ if status == errSecSuccess { return [] }
179
+ throw keychainError(status, operation: "keys")
180
+ }
181
+
102
182
  let allKeys = items.compactMap { $0[kSecAttrAccount as String] as? String }
103
-
183
+
104
184
  guard let pattern = pattern else {
105
185
  return allKeys
106
186
  }
107
-
108
- // Filter keys by pattern (simple prefix matching)
187
+
188
+ // Filter keys by pattern (simple prefix / contains matching)
109
189
  return allKeys.filter { key in
110
190
  key.hasPrefix(pattern) || key.contains(pattern)
111
191
  }
112
192
  }
113
-
114
- private func createQuery(key: String) -> [String: Any] {
193
+
194
+ private func createQuery(key: String, accessible: CFString? = nil) -> [String: Any] {
115
195
  var query: [String: Any] = [
116
196
  kSecClass as String: kSecClassGenericPassword,
117
197
  kSecAttrService as String: service,
118
- kSecAttrAccount as String: key,
119
- kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
198
+ kSecAttrAccount as String: key
120
199
  ]
121
-
200
+
201
+ // Accessibility only needs to be set when adding/writing an item.
202
+ // Including it on read/delete queries can over-constrain matching.
203
+ if let accessible = accessible {
204
+ query[kSecAttrAccessible as String] = accessible
205
+ }
206
+
122
207
  if let accessGroup = accessGroup {
123
208
  query[kSecAttrAccessGroup as String] = accessGroup
124
209
  }
125
-
210
+
126
211
  return query
127
212
  }
128
-
213
+
129
214
  @objc public func size() throws -> (total: Int, count: Int) {
130
215
  let allKeys = try keys()
131
216
  var totalSize = 0
132
-
217
+
133
218
  for key in allKeys {
134
- if let data = try get(key: key) as? Data {
135
- totalSize += data.count
136
- } else if let string = try get(key: key) as? String {
137
- totalSize += string.data(using: .utf8)?.count ?? 0
219
+ if let value = try get(key: key) {
220
+ if let data = value as? Data {
221
+ totalSize += data.count
222
+ } else if let string = value as? String {
223
+ totalSize += string.data(using: .utf8)?.count ?? 0
224
+ } else if let jsonData = try? JSONSerialization.data(withJSONObject: value, options: []) {
225
+ totalSize += jsonData.count
226
+ }
138
227
  }
139
228
  totalSize += key.data(using: .utf8)?.count ?? 0
140
229
  }
141
-
230
+
142
231
  return (total: totalSize, count: allKeys.count)
143
232
  }
144
- }
233
+ }