@octane-xplat/secure-storage 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -1,24 +1,22 @@
1
1
  # `@octane-xplat/secure-storage`
2
2
 
3
- Keychain/Keystore-backed secret storage for Octane xplat apps. iOS writes
4
- to the Keychain and Android to the Keystore via
5
- `@nativescript/secure-storage`; Linux goes through the desktop host bridge
6
- to the Secret Service API (`org.freedesktop.secrets`); web and the macOS
7
- AppKit dev host report `supported: false` — IndexedDB and NSUserDefaults
8
- are not a trust boundary, so the leaf refuses to pretend.
9
-
10
3
  ```sh
11
4
  pnpm add @octane-xplat/secure-storage
12
5
  ```
13
6
 
7
+ Store session tokens without displaying their contents. iOS and macOS use
8
+ Keychain, Android uses the Keystore-backed plugin, and Linux uses the desktop
9
+ host's Secret Service adapter. Plain browsers report `supported: false`.
10
+
14
11
  ```ts
15
12
  import { secureStorage } from '@octane-xplat/secure-storage'
16
13
 
17
- if (secureStorage.supported) {
18
- const store = secureStorage.impl!
19
- await store.set('session-token', token)
20
- const token = await store.get('session-token')
21
- await store.remove('session-token')
14
+ // Call with a token issued by your app's backend; never log its value.
15
+ async function saveSession(token: string) {
16
+ if ((await secureStorage.ensure()) !== 'granted' || !secureStorage.impl) return null
17
+ const store = secureStorage.impl
18
+ if (!(await store.set('session-token', token))) return null
19
+ return await store.get('session-token')
22
20
  }
23
21
  ```
24
22
 
@@ -27,8 +25,47 @@ implementation exists on this target — branch on it rather than catching —
27
25
  `ensure()` resolves the permission/availability state, and `impl` is an
28
26
  async string key-value store (`get` / `set` / `remove`).
29
27
 
28
+ ```ts
29
+ if ((await secureStorage.ensure()) === 'granted') {
30
+ await secureStorage.impl!.remove('session-token')
31
+ }
32
+ ```
33
+
30
34
  For non-secret preferences, use `storage` from
31
35
  [`@octane-xplat/platform`](../platform/README.md) instead.
32
36
 
33
- Guide: [Using device features](../../docs/platform-services.md);
34
- per-target availability: [platform notes](../../docs/platform-notes.md).
37
+ Guide: [Using device features](../../docs/platform/platform-services.md);
38
+ per-target availability: [platform notes](../../docs/notes/platform-notes.md).
39
+
40
+ ## macOS AppKit
41
+
42
+ Install the leaf as an app runtime dependency. Use the CLI-owned
43
+ `pnpm xplat dev --targets macos` or `pnpm xplat build --targets macos` workflow;
44
+ it compiles the bundled Foundation/Security sources and loads their metadata
45
+ before your JavaScript. See [native prerequisites](../../docs/platform/macos-native.md#prepare-the-app).
46
+ No new UI dependency, host adapter, or runtime permission request is needed.
47
+ A custom host without the native leaf reports unsupported.
48
+
49
+ Keychain generic-password items use a service namespace owned by this leaf.
50
+ Packaged apps are scoped by bundle identifier; keep that identifier stable
51
+ across releases. CLI development hosts are scoped by the app's working directory.
52
+ Moving that directory changes the development namespace, and development entries
53
+ are separate from packaged entries. Items stay on this Mac and do not use iCloud
54
+ synchronization. `remove()` deletes only the named item in that namespace.
55
+
56
+ `ensure()` reports that the native implementation exists; it does not unlock the
57
+ Keychain or guarantee the next operation succeeds. No authentication prompt is
58
+ opened by the leaf. A missing key returns `null`; a failed read rejects with
59
+ `Keychain read failed`, without keys, values, or native exception text. Writing the
60
+ same key overwrites its value, and empty strings are preserved. Writes
61
+ return `false` when Keychain access fails. Removal returns `true` for an already
62
+ missing key and `false` for access failures. Handle these results in your sign-in
63
+ flow rather than falling back to preferences or browser storage.
64
+
65
+ From the repository root, `pnpm --filter @octane-xplat/secure-storage test:macos`
66
+ checks the adapter and sanitized failures. `test:packed` checks package types;
67
+ `test:macos-runtime` compiles a packed leaf and exercises real Keychain operations
68
+ in an isolated host, including persistence across launches, bundle identity isolation,
69
+ and a Vite build of its public import. That fixture creates
70
+ random payloads in memory, prints only status markers, and removes its test items.
71
+ It does not test UI input or locked-Keychain/signing policies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octane-xplat/secure-storage",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Keychain/Keystore-backed secure storage for Octane xplat — matching web + NativeScript implementations",
5
5
  "keywords": [
6
6
  "android",
@@ -22,12 +22,17 @@
22
22
  "directory": "packages/secure-storage"
23
23
  },
24
24
  "files": [
25
- "src"
25
+ "src",
26
+ "types",
27
+ "platforms"
26
28
  ],
27
29
  "type": "module",
28
30
  "exports": {
29
31
  ".": {
30
- "macos": "./src/index.macos.ts",
32
+ "macos": {
33
+ "types": "./types/index.macos.d.ts",
34
+ "default": "./src/index.macos.ts"
35
+ },
31
36
  "default": "./src/index.ts"
32
37
  },
33
38
  "./*": "./src/*"
@@ -37,12 +42,13 @@
37
42
  },
38
43
  "dependencies": {
39
44
  "@nativescript/secure-storage": "4.0.2",
40
- "@octane-xplat/platform": "0.9.0"
45
+ "@octane-xplat/platform": "0.10.0"
41
46
  },
42
47
  "devDependencies": {
43
48
  "@nativescript-community/octane": "0.2.4",
44
49
  "@nativescript/core": "9.1.2",
45
- "octane": "0.6.3"
50
+ "octane": "0.6.3",
51
+ "typescript": "5.9.3"
46
52
  },
47
53
  "peerDependencies": {
48
54
  "@nativescript-community/octane": ">=0.2.1 <1",
@@ -63,7 +69,17 @@
63
69
  "ios": "9.1.0"
64
70
  }
65
71
  },
72
+ "xplat": {
73
+ "macos": {
74
+ "frameworks": [
75
+ "Foundation",
76
+ "Security"
77
+ ]
78
+ }
79
+ },
66
80
  "scripts": {
67
- "test:packed": "node tests/packed-consumer.mjs"
81
+ "test:packed": "node tests/macos.test.mjs && node tests/packed-consumer.mjs",
82
+ "test:macos": "node tests/macos.test.mjs",
83
+ "test:macos-runtime": "node tests/macos-runtime.mjs"
68
84
  }
69
85
  }
@@ -0,0 +1,9 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+ @interface XplatSecureStorage : NSObject
5
+ + (NSDictionary *)get:(NSString *)key;
6
+ + (BOOL)set:(NSDictionary *)options;
7
+ + (BOOL)remove:(NSString *)key;
8
+ @end
9
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,60 @@
1
+ #import "XplatSecureStorage.h"
2
+ #import <Security/Security.h>
3
+
4
+ @implementation XplatSecureStorage
5
+ + (NSMutableDictionary *)query:(NSString *)key {
6
+ // Packaged apps use their bundle ID. CLI development hosts share an
7
+ // executable, so scope those entries to the app's working directory.
8
+ NSString *identity = NSBundle.mainBundle.bundleIdentifier;
9
+ if (!identity.length) {
10
+ identity = [@"dev:" stringByAppendingString:NSFileManager.defaultManager.currentDirectoryPath];
11
+ }
12
+ return [@{
13
+ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
14
+ (__bridge id)kSecAttrService: [@"org.octane.xplat.secure-storage:" stringByAppendingString:identity],
15
+ (__bridge id)kSecAttrAccount: key,
16
+ (__bridge id)kSecAttrSynchronizable: @NO,
17
+ (__bridge id)kSecUseAuthenticationUI: (__bridge id)kSecUseAuthenticationUIFail,
18
+ } mutableCopy];
19
+ }
20
+
21
+ + (NSDictionary *)get:(NSString *)key {
22
+ NSMutableDictionary *query = [self query:key];
23
+ query[(__bridge id)kSecReturnData] = @YES;
24
+ query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
25
+ CFTypeRef item = NULL;
26
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &item);
27
+ NSData *data = CFBridgingRelease(item);
28
+ NSString *value = status == errSecSuccess && [data isKindOfClass:NSData.class]
29
+ ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : nil;
30
+ if (status == errSecSuccess && !value) status = errSecDecode;
31
+ return value ? @{ @"status": @(status), @"value": value } : @{ @"status": @(status) };
32
+ }
33
+
34
+ + (BOOL)set:(NSDictionary *)options {
35
+ NSString *key = options[@"key"];
36
+ NSString *value = options[@"value"];
37
+ if (![key isKindOfClass:NSString.class] || ![value isKindOfClass:NSString.class]) return NO;
38
+ NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding];
39
+ if (!data) return NO;
40
+ NSMutableDictionary *query = [self query:key];
41
+ NSDictionary *update = @{ (__bridge id)kSecValueData: data };
42
+ OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)query, (__bridge CFDictionaryRef)update);
43
+ if (status == errSecSuccess) return YES;
44
+ if (status != errSecItemNotFound) return NO;
45
+ query[(__bridge id)kSecValueData] = data;
46
+ query[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly;
47
+ status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
48
+ // Another caller can insert between update and add. Never delete an
49
+ // existing item to overwrite it: preserve its access controls and value.
50
+ if (status == errSecDuplicateItem) {
51
+ return SecItemUpdate((__bridge CFDictionaryRef)[self query:key], (__bridge CFDictionaryRef)update) == errSecSuccess;
52
+ }
53
+ return status == errSecSuccess;
54
+ }
55
+
56
+ + (BOOL)remove:(NSString *)key {
57
+ OSStatus status = SecItemDelete((__bridge CFDictionaryRef)[self query:key]);
58
+ return status == errSecSuccess || status == errSecItemNotFound;
59
+ }
60
+ @end
@@ -1,9 +1,55 @@
1
- // Secure storage — AppKit host leaf. The dev host exposes NSUserDefaults,
2
- // which is not a trust boundary, so the capability stays unsupported.
1
+ // Native APIs are loaded by the CLI's platforms/macos leaf loader.
3
2
  import type { Capability, SecureStore } from './types'
4
3
 
4
+ interface NativeSecureStorage {
5
+ get(key: string): { objectForKey(key: string): unknown }
6
+ set(options: { key: string; value: string }): boolean
7
+ remove(key: string): boolean
8
+ }
9
+
10
+ declare const XplatSecureStorage: NativeSecureStorage | undefined
11
+ const native = () => (typeof XplatSecureStorage === 'undefined' ? null : XplatSecureStorage)
12
+
13
+ const store: SecureStore = {
14
+ async get(key) {
15
+ try {
16
+ const result = native()?.get(key)
17
+ if (!result || result.objectForKey('status') === -25300) {
18
+ return null
19
+ } // errSecItemNotFound
20
+
21
+ const value = result.objectForKey('value')
22
+ if (result.objectForKey('status') === 0 && typeof value === 'string') {
23
+ return value
24
+ }
25
+ } catch {
26
+ // Native exceptions may contain arguments. Never forward them.
27
+ }
28
+
29
+ throw new Error('Keychain read failed')
30
+ },
31
+ async set(key, value) {
32
+ try {
33
+ return native()?.set({ key, value }) ?? false
34
+ } catch {
35
+ return false
36
+ }
37
+ },
38
+ async remove(key) {
39
+ try {
40
+ return native()?.remove(key) ?? false
41
+ } catch {
42
+ return false
43
+ }
44
+ },
45
+ }
46
+
5
47
  export const secureStorage: Capability<SecureStore> = {
6
- supported: false,
7
- ensure: async () => 'unsupported',
8
- impl: null,
48
+ get supported() {
49
+ return native() !== null
50
+ },
51
+ ensure: async () => (native() ? 'granted' : 'unsupported'),
52
+ get impl() {
53
+ return native() ? store : null
54
+ },
9
55
  }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** Optional capability — never throws for absence (docs/platform-services.md). */
1
+ /** Optional capability — never throws for absence (docs/platform/platform-services.md). */
2
2
  export interface Capability<T> {
3
3
  supported: boolean
4
4
  ensure(): Promise<'granted' | 'denied' | 'unsupported'>
@@ -0,0 +1,3 @@
1
+ import type { Capability, SecureStore } from '../src/types.js'
2
+ export type { Capability, SecureStore } from '../src/types.js'
3
+ export declare const secureStorage: Capability<SecureStore>