@octane-xplat/secure-storage 0.0.1 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Alec Larson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,71 @@
1
- # @octane-xplat/secure-storage
1
+ # `@octane-xplat/secure-storage`
2
2
 
3
- Version 0.0.1 is a reservation stub so the automated release can publish this package name. It has no supported API. Use a later release for the implementation.
3
+ ```sh
4
+ pnpm add @octane-xplat/secure-storage
5
+ ```
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
+
11
+ ```ts
12
+ import { secureStorage } from '@octane-xplat/secure-storage'
13
+
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')
20
+ }
21
+ ```
22
+
23
+ The shared capability contract: `supported` reports whether an
24
+ implementation exists on this target — branch on it rather than catching —
25
+ `ensure()` resolves the permission/availability state, and `impl` is an
26
+ async string key-value store (`get` / `set` / `remove`).
27
+
28
+ ```ts
29
+ if ((await secureStorage.ensure()) === 'granted') {
30
+ await secureStorage.impl!.remove('session-token')
31
+ }
32
+ ```
33
+
34
+ For non-secret preferences, use `storage` from
35
+ [`@octane-xplat/platform`](../platform/README.md) instead.
36
+
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,19 +1,85 @@
1
1
  {
2
2
  "name": "@octane-xplat/secure-storage",
3
- "version": "0.0.1",
3
+ "version": "0.10.0",
4
4
  "description": "Keychain/Keystore-backed secure storage for Octane xplat — matching web + NativeScript implementations",
5
+ "keywords": [
6
+ "android",
7
+ "cross-platform",
8
+ "ios",
9
+ "keychain",
10
+ "keystore",
11
+ "nativescript",
12
+ "octane",
13
+ "secure-storage",
14
+ "web",
15
+ "xplat"
16
+ ],
17
+ "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/secure-storage",
5
18
  "license": "MIT",
6
19
  "repository": {
7
20
  "type": "git",
8
- "url": "https://github.com/octane-xplat/octane-xplat.git",
21
+ "url": "https://github.com/octane-xplat/octane-xplat",
9
22
  "directory": "packages/secure-storage"
10
23
  },
11
- "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/secure-storage",
12
- "main": "./index.js",
13
- "types": "./index.d.ts",
14
24
  "files": [
15
- "README.md",
16
- "index.js",
17
- "index.d.ts"
18
- ]
19
- }
25
+ "src",
26
+ "types",
27
+ "platforms"
28
+ ],
29
+ "type": "module",
30
+ "exports": {
31
+ ".": {
32
+ "macos": {
33
+ "types": "./types/index.macos.d.ts",
34
+ "default": "./src/index.macos.ts"
35
+ },
36
+ "default": "./src/index.ts"
37
+ },
38
+ "./*": "./src/*"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@nativescript/secure-storage": "4.0.2",
45
+ "@octane-xplat/platform": "0.10.0"
46
+ },
47
+ "devDependencies": {
48
+ "@nativescript-community/octane": "0.2.4",
49
+ "@nativescript/core": "9.1.2",
50
+ "octane": "0.6.3",
51
+ "typescript": "5.9.3"
52
+ },
53
+ "peerDependencies": {
54
+ "@nativescript-community/octane": ">=0.2.1 <1",
55
+ "@nativescript/core": ">=9.1.0 <10",
56
+ "octane": ">=0.6.3 <1"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "@nativescript-community/octane": {
60
+ "optional": true
61
+ },
62
+ "@nativescript/core": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "nativescript": {
67
+ "platforms": {
68
+ "android": "9.1.0",
69
+ "ios": "9.1.0"
70
+ }
71
+ },
72
+ "xplat": {
73
+ "macos": {
74
+ "frameworks": [
75
+ "Foundation",
76
+ "Security"
77
+ ]
78
+ }
79
+ },
80
+ "scripts": {
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"
84
+ }
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
@@ -0,0 +1,2 @@
1
+ export * from './types'
2
+ export { secureStorage } from './secure-storage.macos'
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './types'
2
+ export { secureStorage } from './secure-storage'
@@ -0,0 +1,2 @@
1
+ // Linux's system webview uses the shared host-aware web implementation.
2
+ export { secureStorage } from './secure-storage.web'
@@ -0,0 +1,55 @@
1
+ // Native APIs are loaded by the CLI's platforms/macos leaf loader.
2
+ import type { Capability, SecureStore } from './types'
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
+
47
+ export const secureStorage: Capability<SecureStore> = {
48
+ get supported() {
49
+ return native() !== null
50
+ },
51
+ ensure: async () => (native() ? 'granted' : 'unsupported'),
52
+ get impl() {
53
+ return native() ? store : null
54
+ },
55
+ }
@@ -0,0 +1,17 @@
1
+ // Secure storage — Keychain (iOS) / Keystore (Android) via plugin.
2
+ import { SecureStorage } from '@nativescript/secure-storage'
3
+ import type { Capability } from './types'
4
+ import type { SecureStore } from './types'
5
+
6
+ const store = new SecureStorage()
7
+
8
+ export const secureStorage: Capability<SecureStore> = {
9
+ supported: true,
10
+ // No runtime permission needed — Keychain/Keystore is device-owned.
11
+ ensure: async () => 'granted',
12
+ impl: {
13
+ get: (key) => store.get({ key }).then((v) => v ?? null),
14
+ set: (key, value) => store.set({ key, value }),
15
+ remove: (key) => store.remove({ key }),
16
+ },
17
+ }
@@ -0,0 +1,23 @@
1
+ // Secure storage — plain web has no equivalent trust boundary. Desktop
2
+ // webview hosts can provide one through the typed host bridge.
3
+ import { desktopHost } from '@octane-xplat/platform/host/web'
4
+ import type { Capability, SecureStore } from './types'
5
+
6
+ const hostStore: SecureStore = {
7
+ get: (key) => desktopHost()?.secureStorage.get(key) ?? Promise.resolve(null),
8
+ set: (key, value) => desktopHost()?.secureStorage.set(key, value) ?? Promise.resolve(false),
9
+ remove: (key) => desktopHost()?.secureStorage.remove(key) ?? Promise.resolve(false),
10
+ }
11
+
12
+ export const secureStorage: Capability<SecureStore> = {
13
+ get supported() {
14
+ return desktopHost() !== null
15
+ },
16
+ async ensure() {
17
+ const host = desktopHost()
18
+ return host && (await host.supports('secureStorage', 'get')) ? 'granted' : 'unsupported'
19
+ },
20
+ get impl() {
21
+ return desktopHost() ? hostStore : null
22
+ },
23
+ }
package/src/types.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** Optional capability — never throws for absence (docs/platform/platform-services.md). */
2
+ export interface Capability<T> {
3
+ supported: boolean
4
+ ensure(): Promise<'granted' | 'denied' | 'unsupported'>
5
+ /** usable iff supported && ensured */
6
+ impl: T | null
7
+ }
8
+
9
+ export interface SecureStore {
10
+ get(key: string): Promise<string | null>
11
+ set(key: string, value: string): Promise<boolean>
12
+ remove(key: string): Promise<boolean>
13
+ }
@@ -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>
package/index.d.ts DELETED
@@ -1 +0,0 @@
1
- export {}
package/index.js DELETED
@@ -1,2 +0,0 @@
1
- // Reservation stub; implementation will arrive in a later release.
2
- module.exports = {}