@appattest/react-native 0.1.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
+ MIT License
2
+
3
+ Copyright (c) 2026 Bault LLC
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 all
13
+ 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 THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @appattest/react-native
2
+
3
+ React Native bridge for [AppAttest](https://www.appattest.dev) — App-Attest-gated
4
+ secret delivery for iOS.
5
+
6
+ > **Status: pre-release.** Ships in lockstep with the Swift SDK at
7
+ > `v0.1.0`. Not yet published to npm.
8
+ > The JS/TS API shape is stable.
9
+
10
+ ## Platform support
11
+
12
+ - **iOS 17+** — full support. Uses Apple's `DCAppAttestService` via the
13
+ native AppAttest Swift SDK.
14
+ - **Android / other** — not supported. Apple's App Attest is iOS-only and
15
+ there is no equivalent on other platforms; the native module is not
16
+ registered outside iOS.
17
+
18
+ ## Install
19
+
20
+ > Not yet on npm — publication is upcoming. Until then the bridge ships as
21
+ > source in `bridges/react-native` of the SDK repository. Once published:
22
+
23
+ ```bash
24
+ npm install @appattest/react-native
25
+ cd ios && pod install
26
+ ```
27
+
28
+ The iOS pod depends on `AppAttestObjC` (a companion pod from the same
29
+ monorepo). That's wired automatically through the pod's `s.dependency`.
30
+
31
+ ## Quick start
32
+
33
+ ```tsx
34
+ import { AppAttest, useSecret } from '@appattest/react-native';
35
+
36
+ // Once, at app launch:
37
+ AppAttest.start();
38
+
39
+ // In any component — re-renders when secrets land:
40
+ function Chat() {
41
+ const openaiKey = useSecret('OPENAI_API_KEY');
42
+ if (!openaiKey) return <Splash />;
43
+ return <ChatView apiKey={openaiKey} />;
44
+ }
45
+ ```
46
+
47
+ Outside components:
48
+
49
+ ```ts
50
+ await AppAttest.waitForReady();
51
+ const key = await AppAttest.getSecret('OPENAI_API_KEY'); // string | null
52
+ const all = await AppAttest.getAllSecrets(); // Record<string, string>
53
+ ```
54
+
55
+ `start()` is fire-and-forget: the first launch attests the device once
56
+ (persists across launches), then syncs secrets; later launches hydrate
57
+ from the Keychain and re-sync in the background. Foreground re-entry
58
+ re-syncs automatically — your app does no lifecycle wiring.
59
+
60
+ ## State
61
+
62
+ ```ts
63
+ import { AppAttest, useAppAttestState } from '@appattest/react-native';
64
+
65
+ const state = useAppAttestState(); // { name, error? }, re-renders on change
66
+
67
+ // or imperatively:
68
+ const s = await AppAttest.getState();
69
+ const unsubscribe = AppAttest.addStateListener((s) => console.log(s.name));
70
+ ```
71
+
72
+ `state.name` is one of `'initializing' | 'attesting' | 'syncing' | 'ready' |
73
+ 'subscription_required' | 'credits_required' | 'unavailable'`. The
74
+ non-`ready` terminal states carry `state.error`.
75
+
76
+ **End-user-facing apps:** show a generic "temporarily unavailable" notice
77
+ for the non-`ready` terminal states. **Developer / staff builds:** log the
78
+ full error (including `actionUrl`) so the developer knows whether to
79
+ subscribe, top up, or investigate.
80
+
81
+ ## Refresh & recovery
82
+
83
+ ```ts
84
+ await AppAttest.retry(); // re-run the sync (no re-attestation)
85
+ await AppAttest.invalidateBundle(); // drop the cached bundle, force a fresh sync
86
+ await AppAttest.reset(); // full wipe; next start() re-attests
87
+ ```
88
+
89
+ `retry()` recovers from transient failures. `invalidateBundle()` forces
90
+ fresh secret bytes when you don't want to wait for the next rotation
91
+ pickup. `reset()` is the nuclear option, for sign-out / data-clearing flows.
92
+
93
+ ## Debug mode (simulator, tests, CI)
94
+
95
+ The simulator can't produce a real App Attest attestation. Use local stubs:
96
+
97
+ ```ts
98
+ if (__DEV__) {
99
+ await AppAttest.setDebugMode('local', {
100
+ OPENAI_API_KEY: 'sk-test-stub',
101
+ });
102
+ }
103
+ AppAttest.start();
104
+ ```
105
+
106
+ Pass `null` to return to real attestation. The native debug surface is
107
+ `#if DEBUG`-gated — physically absent from Release builds, which always
108
+ run real attestation; calling it there rejects with
109
+ `debug_mode_release_blocked`.
110
+
111
+ Dev builds on **real devices** don't need debug mode — they attest for
112
+ real and read the sandbox bucket (below).
113
+
114
+ ## Buckets (sandbox vs production)
115
+
116
+ There is no environment configuration. Apple's AAGUID in each attestation
117
+ determines the bucket server-side: dev / TestFlight builds read the
118
+ **sandbox** secrets column; App Store builds read **production**. Same
119
+ code in both, no flags.
120
+
121
+ ## Error handling
122
+
123
+ All methods reject with `AppAttestError` (`code`, plus `subscribeUrl` /
124
+ `topupUrl` / `actionUrl` on the billing cases):
125
+
126
+ ```ts
127
+ import { AppAttest, AppAttestError, ErrorCode } from '@appattest/react-native';
128
+
129
+ try {
130
+ await AppAttest.waitForReady();
131
+ } catch (e) {
132
+ if (e instanceof AppAttestError && e.code === ErrorCode.SubscriptionRequired) {
133
+ console.log('project needs a subscription:', e.actionUrl);
134
+ }
135
+ }
136
+ ```
137
+
138
+ | Code | Meaning |
139
+ |------|---------|
140
+ | `subscription_required` | Project subscription not active (`subscribeUrl`). |
141
+ | `credits_required` | Allowance exhausted and balance empty (`topupUrl`). |
142
+ | `attestation_rejected` | Apple or AppAttest rejected this install — terminal until reinstall. |
143
+ | `service_unavailable` | Temporary service condition; retryable (the SDK backs off automatically). |
144
+ | `network` | Device-side transport failure; retryable. |
145
+ | `debug_mode_release_blocked` | `setDebugMode` called in a Release build. |
146
+ | `invalid_argument` | Malformed call input. |
147
+
148
+ ## License
149
+
150
+ MIT © 2026 Bault LLC. See [LICENSE](LICENSE).
@@ -0,0 +1,52 @@
1
+ // AppAttestModule.mm — React Native module registration macros.
2
+ //
3
+ // Exposes the Swift class `AppAttestModule` to RN as `RNAppAttest`
4
+ // (matches the JS-side TurboModuleRegistry.getEnforcing<Spec>('RNAppAttest')).
5
+ // Each RCT_EXTERN_METHOD declares a method the RN runtime may invoke and
6
+ // its argument shape. Keep in lockstep with ../src/NativeAppAttest.ts.
7
+
8
+ #import <React/RCTBridgeModule.h>
9
+ #import <React/RCTEventEmitter.h>
10
+
11
+ #if __has_include(<React/RCTBridgeModule.h>)
12
+ @interface RCT_EXTERN_REMAP_MODULE(RNAppAttest, AppAttestModule, RCTEventEmitter)
13
+
14
+ RCT_EXTERN_METHOD(start:(RCTPromiseResolveBlock)resolve
15
+ reject:(RCTPromiseRejectBlock)reject)
16
+
17
+ RCT_EXTERN_METHOD(waitForReady:(RCTPromiseResolveBlock)resolve
18
+ reject:(RCTPromiseRejectBlock)reject)
19
+
20
+ RCT_EXTERN_METHOD(retry:(RCTPromiseResolveBlock)resolve
21
+ reject:(RCTPromiseRejectBlock)reject)
22
+
23
+ RCT_EXTERN_METHOD(reset:(RCTPromiseResolveBlock)resolve
24
+ reject:(RCTPromiseRejectBlock)reject)
25
+
26
+ RCT_EXTERN_METHOD(invalidateBundle:(RCTPromiseResolveBlock)resolve
27
+ reject:(RCTPromiseRejectBlock)reject)
28
+
29
+ RCT_EXTERN_METHOD(getSecret:(NSString *)name
30
+ resolve:(RCTPromiseResolveBlock)resolve
31
+ reject:(RCTPromiseRejectBlock)reject)
32
+
33
+ RCT_EXTERN_METHOD(getAllSecrets:(RCTPromiseResolveBlock)resolve
34
+ reject:(RCTPromiseRejectBlock)reject)
35
+
36
+ RCT_EXTERN_METHOD(getState:(RCTPromiseResolveBlock)resolve
37
+ reject:(RCTPromiseRejectBlock)reject)
38
+
39
+ RCT_EXTERN_METHOD(setDebugMode:(nullable NSString *)name
40
+ stubs:(nullable NSDictionary *)stubs
41
+ resolve:(RCTPromiseResolveBlock)resolve
42
+ reject:(RCTPromiseRejectBlock)reject)
43
+
44
+ // setApiBaseUrl is not exposed — base URL is hardcoded.
45
+
46
+ + (BOOL)requiresMainQueueSetup
47
+ {
48
+ return NO;
49
+ }
50
+
51
+ @end
52
+ #endif
@@ -0,0 +1,173 @@
1
+ // AppAttestModule.swift — iOS native module for @appattest/react-native.
2
+ //
3
+ // Adapter between React Native's module system and the AppAttest SDK's
4
+ // @objc facade (AppAttestObjC). Every method matches the TurboModule spec
5
+ // in ../src/NativeAppAttest.ts.
6
+ //
7
+ // Errors: rejection bodies carry { code, message,
8
+ // subscribeUrl? | topupUrl? }. The 402 envelope key matches the code:
9
+ // `subscription_required` → subscribeUrl; `credits_required` → topupUrl.
10
+ //
11
+ // State events: emitted on `stateChanged` whenever the SDK's state
12
+ // transitions. JS hooks subscribe via the `addStateListener` wrapper.
13
+
14
+ import Foundation
15
+ import AppAttestObjC
16
+ import React
17
+
18
+ @objc(AppAttestModule)
19
+ final class AppAttestModule: RCTEventEmitter {
20
+
21
+ private let client = AppAttestObjCClient.shared
22
+ private var stateToken: AppAttestObservationToken?
23
+ private var hasJSListeners = false
24
+
25
+ override static func requiresMainQueueSetup() -> Bool { false }
26
+
27
+ // MARK: - Event emitter
28
+
29
+ override func supportedEvents() -> [String]! { ["stateChanged"] }
30
+
31
+ override func startObserving() {
32
+ hasJSListeners = true
33
+ stateToken?.invalidate()
34
+ stateToken = client.addStateObserver { [weak self] state in
35
+ guard let self else { return }
36
+ self.sendEvent(withName: "stateChanged", body: Self.encodeState(state))
37
+ }
38
+ }
39
+
40
+ /// Encode an `AppAttestState` for the JS bridge — flattens the nested
41
+ /// NSError userInfo into a JSON-safe dict. Forwards every 402 URL key
42
+ /// (`subscribeUrl`, `topupUrl`); JS picks whichever matches its current
43
+ /// state.
44
+ private static func encodeState(_ state: AppAttestState) -> [String: Any] {
45
+ var body: [String: Any] = ["name": state.name]
46
+ if let err = state.error {
47
+ var errBody: [String: Any] = [
48
+ "code": (err.userInfo["code"] as? String) ?? "internal_error",
49
+ "message": (err.userInfo["message"] as? String) ?? err.localizedDescription
50
+ ]
51
+ if let url = err.userInfo["subscribeUrl"] as? String { errBody["subscribeUrl"] = url }
52
+ if let url = err.userInfo["topupUrl"] as? String { errBody["topupUrl"] = url }
53
+ body["error"] = errBody
54
+ }
55
+ return body
56
+ }
57
+
58
+ override func stopObserving() {
59
+ hasJSListeners = false
60
+ stateToken?.invalidate()
61
+ stateToken = nil
62
+ }
63
+
64
+ // MARK: - Lifecycle
65
+
66
+ @objc func start(
67
+ _ resolve: @escaping RCTPromiseResolveBlock,
68
+ reject: @escaping RCTPromiseRejectBlock
69
+ ) {
70
+ client.start()
71
+ resolve(NSNull())
72
+ }
73
+
74
+ @objc func waitForReady(
75
+ _ resolve: @escaping RCTPromiseResolveBlock,
76
+ reject: @escaping RCTPromiseRejectBlock
77
+ ) {
78
+ client.waitForReady { error in
79
+ Self.complete((), error: error, resolve: resolve, reject: reject)
80
+ }
81
+ }
82
+
83
+ @objc func retry(
84
+ _ resolve: @escaping RCTPromiseResolveBlock,
85
+ reject: @escaping RCTPromiseRejectBlock
86
+ ) {
87
+ client.retry()
88
+ resolve(NSNull())
89
+ }
90
+
91
+ @objc func reset(
92
+ _ resolve: @escaping RCTPromiseResolveBlock,
93
+ reject: @escaping RCTPromiseRejectBlock
94
+ ) {
95
+ client.reset { error in
96
+ Self.complete((), error: error, resolve: resolve, reject: reject)
97
+ }
98
+ }
99
+
100
+ @objc func invalidateBundle(
101
+ _ resolve: @escaping RCTPromiseResolveBlock,
102
+ reject: @escaping RCTPromiseRejectBlock
103
+ ) {
104
+ client.invalidateBundle()
105
+ resolve(NSNull())
106
+ }
107
+
108
+ // MARK: - Reads
109
+
110
+ @objc func getSecret(
111
+ _ name: NSString,
112
+ resolve: @escaping RCTPromiseResolveBlock,
113
+ reject: @escaping RCTPromiseRejectBlock
114
+ ) {
115
+ DispatchQueue.main.async {
116
+ let value = self.client.secret(forKey: name as String)
117
+ resolve(value as Any? ?? NSNull())
118
+ }
119
+ }
120
+
121
+ @objc func getAllSecrets(
122
+ _ resolve: @escaping RCTPromiseResolveBlock,
123
+ reject: @escaping RCTPromiseRejectBlock
124
+ ) {
125
+ DispatchQueue.main.async {
126
+ resolve(self.client.allSecrets())
127
+ }
128
+ }
129
+
130
+ @objc func getState(
131
+ _ resolve: @escaping RCTPromiseResolveBlock,
132
+ reject: @escaping RCTPromiseRejectBlock
133
+ ) {
134
+ DispatchQueue.main.async {
135
+ resolve(Self.encodeState(self.client.currentState()))
136
+ }
137
+ }
138
+
139
+ // MARK: - Configuration
140
+
141
+ @objc func setDebugMode(
142
+ _ name: NSString?,
143
+ stubs: NSDictionary?,
144
+ resolve: @escaping RCTPromiseResolveBlock,
145
+ reject: @escaping RCTPromiseRejectBlock
146
+ ) {
147
+ let dict = (stubs as? [String: String]) ?? [:]
148
+ client.setDebugMode(name as String?, stubs: dict.isEmpty ? nil : dict) { error in
149
+ Self.complete((), error: error, resolve: resolve, reject: reject)
150
+ }
151
+ }
152
+
153
+ // setApiBaseUrl is not exposed — the base URL is hardcoded in the
154
+ // Swift SDK (https://edge.appattest.dev). No way to point at any
155
+ // other URL from a published binary; that's the security model.
156
+
157
+ // MARK: - Promise plumbing
158
+
159
+ private static func complete<T>(
160
+ _ value: T,
161
+ error: NSError?,
162
+ resolve: RCTPromiseResolveBlock,
163
+ reject: RCTPromiseRejectBlock
164
+ ) {
165
+ if let error {
166
+ let code = (error.userInfo["code"] as? String) ?? "internal_error"
167
+ let message = (error.userInfo["message"] as? String) ?? error.localizedDescription
168
+ reject(code, message, error)
169
+ } else {
170
+ resolve(value)
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,34 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'AppAttestReactNative'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.homepage = 'https://github.com/AppAttest/appAttest-sdk'
10
+ s.license = 'MIT'
11
+ s.author = { 'AppAttest' => 'support@appattest.dev' }
12
+ s.source = {
13
+ :git => 'https://github.com/AppAttest/appAttest-sdk.git',
14
+ :tag => "v#{s.version}"
15
+ }
16
+
17
+ s.platforms = { :ios => '17.0' }
18
+ s.swift_versions = ['5.9']
19
+
20
+ s.source_files = '**/*.{swift,m,mm,h}'
21
+
22
+ # Depend on the Swift SDK's Objective-C facade. The facade transitively
23
+ # pulls in `AppAttest` (the pure Swift module).
24
+ s.dependency 'AppAttestObjC', "= #{s.version}"
25
+
26
+ # React Native core — this is the standard pattern from @react-native/
27
+ # official modules. `install_modules_dependencies` is exposed by RN's
28
+ # podspec helpers and ensures new-arch / TurboModule deps are wired.
29
+ if respond_to?(:install_modules_dependencies, true)
30
+ install_modules_dependencies(s)
31
+ else
32
+ s.dependency 'React-Core'
33
+ end
34
+ end
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _reactNative = require("react-native");
8
+ /**
9
+ * TurboModule spec for the AppAttest iOS native module.
10
+ *
11
+ * Codegen reads this file. The shape is restricted to what TurboModule
12
+ * codegen can serialize (primitives, Object, arrays of primitives,
13
+ * Promises, null).
14
+ *
15
+ * Errors: rejection objects carry `{ code, message,
16
+ * subscribeUrl?, topupUrl? }`. The TS wrapper in `index.ts` translates
17
+ * these into `AppAttestError`.
18
+ */
19
+ var _default = exports.default = _reactNative.TurboModuleRegistry.getEnforcing('RNAppAttest');
20
+ //# sourceMappingURL=NativeAppAttest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["_reactNative","require","_default","exports","default","TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeAppAttest.ts"],"mappings":";;;;;;AAYA,IAAAA,YAAA,GAAAC,OAAA;AAZA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AAVA,IAAAC,QAAA,GAAAC,OAAA,CAAAC,OAAA,GAqEeC,gCAAmB,CAACC,YAAY,CAAO,aAAa,CAAC","ignoreList":[]}