@latchway/react-native 0.0.0-bootstrap.0 → 1.0.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/CHANGELOG.md +97 -0
- package/LatchwayReactNative.podspec +33 -0
- package/NOTICE +7 -0
- package/README.md +286 -3
- package/SECURITY.md +69 -0
- package/android/build.gradle.kts +62 -0
- package/android/consumer-rules.pro +2 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/android/gradle.properties +4 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle.kts +33 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/latchway/reactnative/LatchwayReactNativePackage.kt +25 -0
- package/android/src/main/java/dev/latchway/reactnative/NativeLatchwayModule.kt +964 -0
- package/android/src/test/java/dev/latchway/reactnative/NativeLatchwayModuleTest.kt +521 -0
- package/contract.lock +7 -0
- package/docs/architecture.md +145 -0
- package/docs/conformance.md +68 -0
- package/docs/native-installation.md +172 -0
- package/docs/physical-device-evidence.md +559 -0
- package/docs/releasing.md +360 -0
- package/docs/security.md +55 -0
- package/ios/LatchwayNativeBridge.swift +1551 -0
- package/ios/RCTNativeLatchway.h +9 -0
- package/ios/RCTNativeLatchway.mm +175 -0
- package/lib/client.d.ts +29 -0
- package/lib/client.d.ts.map +1 -0
- package/lib/client.js +939 -0
- package/lib/client.js.map +1 -0
- package/lib/component-client.d.ts +15 -0
- package/lib/component-client.d.ts.map +1 -0
- package/lib/component-client.js +141 -0
- package/lib/component-client.js.map +1 -0
- package/lib/config.d.ts +25 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +261 -0
- package/lib/config.js.map +1 -0
- package/lib/coordinator.d.ts +19 -0
- package/lib/coordinator.d.ts.map +1 -0
- package/lib/coordinator.js +167 -0
- package/lib/coordinator.js.map +1 -0
- package/lib/errors.d.ts +5 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +201 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/lib/native/NativeLatchway.d.ts +25 -0
- package/lib/native/NativeLatchway.d.ts.map +1 -0
- package/lib/native/NativeLatchway.js +3 -0
- package/lib/native/NativeLatchway.js.map +1 -0
- package/lib/native/bridge.d.ts +5 -0
- package/lib/native/bridge.d.ts.map +1 -0
- package/lib/native/bridge.js +17 -0
- package/lib/native/bridge.js.map +1 -0
- package/lib/native-output.d.ts +3 -0
- package/lib/native-output.d.ts.map +1 -0
- package/lib/native-output.js +43 -0
- package/lib/native-output.js.map +1 -0
- package/lib/request-id.d.ts +2 -0
- package/lib/request-id.d.ts.map +1 -0
- package/lib/request-id.js +5 -0
- package/lib/request-id.js.map +1 -0
- package/lib/testing.d.ts +7 -0
- package/lib/testing.d.ts.map +1 -0
- package/lib/testing.js +9 -0
- package/lib/testing.js.map +1 -0
- package/lib/types.d.ts +200 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +8 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +8 -0
- package/lib/version.js.map +1 -0
- package/package.json +130 -6
- package/react-native.config.cjs +7 -0
- package/release-compatibility.json +64 -0
- package/src/client.ts +1022 -0
- package/src/component-client.ts +158 -0
- package/src/config.ts +368 -0
- package/src/coordinator.ts +195 -0
- package/src/errors.ts +225 -0
- package/src/index.ts +53 -0
- package/src/native/NativeLatchway.ts +75 -0
- package/src/native/bridge.ts +23 -0
- package/src/native-output.ts +43 -0
- package/src/request-id.ts +5 -0
- package/src/testing.ts +11 -0
- package/src/types.ts +241 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { LatchwayError } from "@latchway/client";
|
|
2
|
+
import type { RuntimeComponentConfiguration, RuntimeConfiguration } from "./config.js";
|
|
3
|
+
import { fromNativeError, nativeUnavailable } from "./errors.js";
|
|
4
|
+
import { assertNoCredentialFields } from "./native-output.js";
|
|
5
|
+
import { nativeModule, type NativeLatchwayModule } from "./native/bridge.js";
|
|
6
|
+
import type { ReactNativePlatform } from "./types.js";
|
|
7
|
+
import { CONTRACT_VERSION, PROTOCOL_VERSION, SDK_VERSION } from "./version.js";
|
|
8
|
+
|
|
9
|
+
interface Compatibility {
|
|
10
|
+
platform: ReactNativePlatform;
|
|
11
|
+
nativeSDKVersion: string;
|
|
12
|
+
contractVersion: string;
|
|
13
|
+
protocolVersion: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Entry {
|
|
17
|
+
clientID: string;
|
|
18
|
+
fingerprint: string;
|
|
19
|
+
module: NativeLatchwayModule;
|
|
20
|
+
ready: Promise<Compatibility>;
|
|
21
|
+
references: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NativeLease {
|
|
25
|
+
clientID: string;
|
|
26
|
+
module: NativeLatchwayModule;
|
|
27
|
+
ready: Promise<Compatibility>;
|
|
28
|
+
release(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const entries = new Map<string, Entry>();
|
|
32
|
+
const componentEntries = new Map<string, Entry>();
|
|
33
|
+
const moduleIDs = new WeakMap<object, number>();
|
|
34
|
+
let nextModuleID = 1;
|
|
35
|
+
let nextClientID = 1;
|
|
36
|
+
|
|
37
|
+
export async function acquire(config: RuntimeConfiguration): Promise<NativeLease> {
|
|
38
|
+
let module: NativeLatchwayModule;
|
|
39
|
+
try {
|
|
40
|
+
module = await nativeModule();
|
|
41
|
+
} catch (cause) {
|
|
42
|
+
throw nativeUnavailable(cause);
|
|
43
|
+
}
|
|
44
|
+
const moduleID = identityFor(module);
|
|
45
|
+
const key = `${moduleID}|${config.scope}`;
|
|
46
|
+
const existing = entries.get(key);
|
|
47
|
+
if (existing !== undefined) {
|
|
48
|
+
if (existing.fingerprint !== config.fingerprint) {
|
|
49
|
+
throw new LatchwayError(
|
|
50
|
+
"client_configuration_invalid",
|
|
51
|
+
"Conflicting Latchway native configuration is active for this application scope.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
existing.references += 1;
|
|
55
|
+
return lease(entries, key, existing);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const clientID = `latchway-rn-${nextClientID++}`;
|
|
59
|
+
const entry: Entry = {
|
|
60
|
+
clientID,
|
|
61
|
+
fingerprint: config.fingerprint,
|
|
62
|
+
module,
|
|
63
|
+
references: 1,
|
|
64
|
+
ready: Promise.resolve({
|
|
65
|
+
platform: "react_native_ios",
|
|
66
|
+
nativeSDKVersion: "",
|
|
67
|
+
contractVersion: "",
|
|
68
|
+
protocolVersion: 0,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
entry.ready = module.configure(clientID, config.nativeJSON)
|
|
72
|
+
.then(parseCompatibility)
|
|
73
|
+
.catch(async (cause: unknown) => {
|
|
74
|
+
if (entries.get(key) === entry) entries.delete(key);
|
|
75
|
+
// configure may have created native state before compatibility parsing
|
|
76
|
+
// failed. Disposal is idempotent on both bridges and its failure must not
|
|
77
|
+
// hide the original configuration error.
|
|
78
|
+
try { await module.dispose(clientID); } catch { /* preserve the original failure */ }
|
|
79
|
+
throw fromNativeError(cause);
|
|
80
|
+
});
|
|
81
|
+
entries.set(key, entry);
|
|
82
|
+
return lease(entries, key, entry);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function acquireComponent(config: RuntimeComponentConfiguration): Promise<NativeLease> {
|
|
86
|
+
let module: NativeLatchwayModule;
|
|
87
|
+
try {
|
|
88
|
+
module = await nativeModule();
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
throw nativeUnavailable(cause);
|
|
91
|
+
}
|
|
92
|
+
const moduleID = identityFor(module);
|
|
93
|
+
const key = `${moduleID}|component|${config.scope}`;
|
|
94
|
+
const existing = componentEntries.get(key);
|
|
95
|
+
if (existing !== undefined) {
|
|
96
|
+
if (existing.fingerprint !== config.fingerprint) {
|
|
97
|
+
throw new LatchwayError(
|
|
98
|
+
"client_configuration_invalid",
|
|
99
|
+
"Conflicting Latchway component configuration is active for this extension scope.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
existing.references += 1;
|
|
103
|
+
return lease(componentEntries, key, existing);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const clientID = `latchway-rn-component-${nextClientID++}`;
|
|
107
|
+
const entry: Entry = {
|
|
108
|
+
clientID,
|
|
109
|
+
fingerprint: config.fingerprint,
|
|
110
|
+
module,
|
|
111
|
+
references: 1,
|
|
112
|
+
ready: emptyCompatibility(),
|
|
113
|
+
};
|
|
114
|
+
entry.ready = module.configureComponent(clientID, config.nativeJSON, config.componentJSON)
|
|
115
|
+
.then(parseCompatibility)
|
|
116
|
+
.catch(async (cause: unknown) => {
|
|
117
|
+
if (componentEntries.get(key) === entry) componentEntries.delete(key);
|
|
118
|
+
try { await module.dispose(clientID); } catch { /* preserve the original failure */ }
|
|
119
|
+
throw fromNativeError(cause);
|
|
120
|
+
});
|
|
121
|
+
componentEntries.set(key, entry);
|
|
122
|
+
return lease(componentEntries, key, entry);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function lease(owner: Map<string, Entry>, key: string, entry: Entry): NativeLease {
|
|
126
|
+
let released = false;
|
|
127
|
+
return {
|
|
128
|
+
clientID: entry.clientID,
|
|
129
|
+
module: entry.module,
|
|
130
|
+
ready: entry.ready,
|
|
131
|
+
async release(): Promise<void> {
|
|
132
|
+
if (released) return;
|
|
133
|
+
released = true;
|
|
134
|
+
entry.references -= 1;
|
|
135
|
+
if (entry.references !== 0 || owner.get(key) !== entry) return;
|
|
136
|
+
owner.delete(key);
|
|
137
|
+
try {
|
|
138
|
+
await entry.ready;
|
|
139
|
+
await entry.module.dispose(entry.clientID);
|
|
140
|
+
} catch (cause) {
|
|
141
|
+
throw fromNativeError(cause);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function emptyCompatibility(): Promise<Compatibility> {
|
|
148
|
+
return Promise.resolve({
|
|
149
|
+
platform: "react_native_ios",
|
|
150
|
+
nativeSDKVersion: "",
|
|
151
|
+
contractVersion: "",
|
|
152
|
+
protocolVersion: 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseCompatibility(encoded: string): Compatibility {
|
|
157
|
+
const value = parseRecord(encoded, "native compatibility");
|
|
158
|
+
assertNoCredentialFields(value);
|
|
159
|
+
if (!hasOnlyKeys(value, ["platform", "nativeSDKVersion", "contractVersion", "protocolVersion"]) ||
|
|
160
|
+
(value.platform !== "react_native_ios" && value.platform !== "react_native_android") ||
|
|
161
|
+
typeof value.nativeSDKVersion !== "string" || value.nativeSDKVersion.length === 0 ||
|
|
162
|
+
value.nativeSDKVersion.length > 128 || /\p{Cc}/u.test(value.nativeSDKVersion) ||
|
|
163
|
+
value.contractVersion !== CONTRACT_VERSION || value.protocolVersion !== PROTOCOL_VERSION) {
|
|
164
|
+
throw new LatchwayError(
|
|
165
|
+
"protocol_response_invalid",
|
|
166
|
+
`The native Latchway SDK is incompatible with JavaScript SDK ${SDK_VERSION}.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return value as unknown as Compatibility;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasOnlyKeys(value: Record<string, unknown>, names: readonly string[]): boolean {
|
|
173
|
+
const expected = new Set(names);
|
|
174
|
+
return Object.keys(value).every((name) => expected.has(name));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseRecord(encoded: string, label: string): Record<string, unknown> {
|
|
178
|
+
try {
|
|
179
|
+
const value: unknown = JSON.parse(encoded);
|
|
180
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
181
|
+
return value as Record<string, unknown>;
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// Report only a stable protocol error; native output may contain sensitive detail.
|
|
185
|
+
}
|
|
186
|
+
throw new LatchwayError("protocol_response_invalid", `Latchway returned invalid ${label}.`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function identityFor(module: object): number {
|
|
190
|
+
const existing = moduleIDs.get(module);
|
|
191
|
+
if (existing !== undefined) return existing;
|
|
192
|
+
const value = nextModuleID++;
|
|
193
|
+
moduleIDs.set(module, value);
|
|
194
|
+
return value;
|
|
195
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { LatchwayError } from "@latchway/client";
|
|
2
|
+
import type { LatchwayErrorCode } from "@latchway/client";
|
|
3
|
+
import { isCanonicalRequestID } from "./request-id.js";
|
|
4
|
+
import { assertNoCredentialFields } from "./native-output.js";
|
|
5
|
+
|
|
6
|
+
const knownCodeMap = {
|
|
7
|
+
request_invalid: true,
|
|
8
|
+
identity_token_missing: true,
|
|
9
|
+
identity_token_invalid: true,
|
|
10
|
+
identity_token_expired: true,
|
|
11
|
+
identity_reauthentication_required: true,
|
|
12
|
+
attestation_required: true,
|
|
13
|
+
attestation_unsupported: true,
|
|
14
|
+
attestation_invalid: true,
|
|
15
|
+
attestation_stale: true,
|
|
16
|
+
attestation_step_up_required: true,
|
|
17
|
+
dpop_missing: true,
|
|
18
|
+
dpop_invalid: true,
|
|
19
|
+
dpop_replayed: true,
|
|
20
|
+
dpop_nonce_required: true,
|
|
21
|
+
session_expired: true,
|
|
22
|
+
session_revoked: true,
|
|
23
|
+
refresh_token_reused: true,
|
|
24
|
+
installation_revoked: true,
|
|
25
|
+
installation_family_revoked: true,
|
|
26
|
+
installation_family_not_found: true,
|
|
27
|
+
component_definition_not_found: true,
|
|
28
|
+
component_not_configured: true,
|
|
29
|
+
component_not_provisioned: true,
|
|
30
|
+
component_revoked: true,
|
|
31
|
+
component_key_invalid: true,
|
|
32
|
+
component_key_replaced: true,
|
|
33
|
+
component_delegation_expired: true,
|
|
34
|
+
component_feature_not_granted: true,
|
|
35
|
+
component_parent_trust_expired: true,
|
|
36
|
+
component_direct_attestation_required: true,
|
|
37
|
+
containing_app_setup_required: true,
|
|
38
|
+
framework_integration_unsupported: true,
|
|
39
|
+
framework_version_unsupported: true,
|
|
40
|
+
transport_destination_not_allowed: true,
|
|
41
|
+
transport_request_not_replayable: true,
|
|
42
|
+
feature_not_found: true,
|
|
43
|
+
feature_not_allowed: true,
|
|
44
|
+
model_not_allowed: true,
|
|
45
|
+
quota_exceeded: true,
|
|
46
|
+
concurrency_exceeded: true,
|
|
47
|
+
output_limit_exceeded: true,
|
|
48
|
+
pricing_unavailable: true,
|
|
49
|
+
route_not_found: true,
|
|
50
|
+
upstream_unavailable: true,
|
|
51
|
+
upstream_timeout: true,
|
|
52
|
+
upstream_protocol_error: true,
|
|
53
|
+
configuration_invalid: true,
|
|
54
|
+
server_not_ready: true,
|
|
55
|
+
protocol_version_unsupported: true,
|
|
56
|
+
authentication_required: true,
|
|
57
|
+
permission_denied: true,
|
|
58
|
+
resource_not_found: true,
|
|
59
|
+
conflict: true,
|
|
60
|
+
etag_required: true,
|
|
61
|
+
etag_mismatch: true,
|
|
62
|
+
bootstrap_disabled: true,
|
|
63
|
+
rate_limited: true,
|
|
64
|
+
operation_indeterminate: true,
|
|
65
|
+
internal_error: true,
|
|
66
|
+
client_configuration_invalid: true,
|
|
67
|
+
storage_unavailable: true,
|
|
68
|
+
crypto_unavailable: true,
|
|
69
|
+
attestation_provider_missing: true,
|
|
70
|
+
protocol_response_invalid: true,
|
|
71
|
+
request_not_replayable: true,
|
|
72
|
+
network_error: true,
|
|
73
|
+
} as const satisfies Record<LatchwayErrorCode, true>;
|
|
74
|
+
|
|
75
|
+
const knownCodes: ReadonlySet<string> = new Set(Object.keys(knownCodeMap));
|
|
76
|
+
|
|
77
|
+
const localCodeMap: Readonly<Record<string, LatchwayErrorCode>> = {
|
|
78
|
+
cancelled: "network_error",
|
|
79
|
+
invalid_configuration: "client_configuration_invalid",
|
|
80
|
+
invalid_request: "request_invalid",
|
|
81
|
+
key_unavailable: "crypto_unavailable",
|
|
82
|
+
secure_enclave_unavailable: "crypto_unavailable",
|
|
83
|
+
secure_state_unavailable: "storage_unavailable",
|
|
84
|
+
key_storage_failure: "storage_unavailable",
|
|
85
|
+
response_invalid: "protocol_response_invalid",
|
|
86
|
+
invalid_server_response: "protocol_response_invalid",
|
|
87
|
+
network_unavailable: "network_error",
|
|
88
|
+
transport_failure: "network_error",
|
|
89
|
+
session_unavailable: "session_expired",
|
|
90
|
+
attestation_unavailable: "attestation_unsupported",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export function fromNativeError(value: unknown): Error {
|
|
94
|
+
try {
|
|
95
|
+
assertNoCredentialFields(value);
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
return cause instanceof LatchwayError
|
|
98
|
+
? cause
|
|
99
|
+
: new LatchwayError("protocol_response_invalid", "Latchway returned unsafe native error metadata.");
|
|
100
|
+
}
|
|
101
|
+
if (isAbortError(value)) return abortError();
|
|
102
|
+
const record = isRecord(value) ? value : {};
|
|
103
|
+
const userInfo = isRecord(record.userInfo) ? record.userInfo : {};
|
|
104
|
+
const rawCode = firstString(record.code, userInfo.code);
|
|
105
|
+
const mapped = rawCode === undefined
|
|
106
|
+
? "internal_error"
|
|
107
|
+
: localCodeMap[rawCode] ?? (knownCodes.has(rawCode)
|
|
108
|
+
? rawCode as LatchwayErrorCode
|
|
109
|
+
: "internal_error");
|
|
110
|
+
const codeValues = presentValues(record, userInfo, ["code"]);
|
|
111
|
+
const requestIDValues = presentValues(record, userInfo, ["requestID", "request_id"]);
|
|
112
|
+
const requestIDCandidate = firstString(record.requestID, record.request_id, userInfo.requestID, userInfo.request_id);
|
|
113
|
+
const requestID = isCanonicalRequestID(requestIDCandidate) ? requestIDCandidate : undefined;
|
|
114
|
+
const statusValues = presentValues(record, userInfo, ["status"]);
|
|
115
|
+
const status = safeStatus(record.status ?? userInfo.status);
|
|
116
|
+
const retryableValues = presentValues(record, userInfo, ["retryable"]);
|
|
117
|
+
const retryable = record.retryable === true || userInfo.retryable === true;
|
|
118
|
+
const documentationURLValues = presentValues(
|
|
119
|
+
record,
|
|
120
|
+
userInfo,
|
|
121
|
+
["documentationURL", "documentation_url"],
|
|
122
|
+
);
|
|
123
|
+
const canonicalNativeDocumentationURL = rawCode !== undefined && isNativeErrorCode(rawCode)
|
|
124
|
+
? `https://docs.latchway.dev/errors/${rawCode.replaceAll("_", "-")}`
|
|
125
|
+
: undefined;
|
|
126
|
+
const documentationURLIsValid = documentationURLValues.length > 0 &&
|
|
127
|
+
canonicalNativeDocumentationURL !== undefined &&
|
|
128
|
+
documentationURLValues.every((candidate) => candidate === canonicalNativeDocumentationURL);
|
|
129
|
+
const operationValues = presentValues(record, userInfo, ["operationID", "operation_id"]);
|
|
130
|
+
const operationID = operationValues.length > 0 &&
|
|
131
|
+
operationValues.every((candidate) => candidate === operationValues[0]) &&
|
|
132
|
+
isCanonicalOperationID(operationValues[0])
|
|
133
|
+
? operationValues[0]
|
|
134
|
+
: undefined;
|
|
135
|
+
const validIndeterminateMetadata = operationID !== undefined && requestID !== undefined &&
|
|
136
|
+
status === 503 && retryable && codeValues.length > 0 &&
|
|
137
|
+
codeValues.every((candidate) => candidate === "operation_indeterminate") &&
|
|
138
|
+
requestIDValues.length > 0 && requestIDValues.every((candidate) => candidate === requestID) &&
|
|
139
|
+
statusValues.length > 0 && statusValues.every((candidate) => candidate === 503) &&
|
|
140
|
+
retryableValues.length > 0 && retryableValues.every((candidate) => candidate === true);
|
|
141
|
+
const hasServerMetadata = requestIDValues.length > 0 || statusValues.length > 0;
|
|
142
|
+
if ((documentationURLValues.length > 0 && !documentationURLIsValid) ||
|
|
143
|
+
(hasServerMetadata && !documentationURLIsValid) ||
|
|
144
|
+
(mapped === "operation_indeterminate" && !validIndeterminateMetadata) ||
|
|
145
|
+
(mapped !== "operation_indeterminate" && operationValues.length > 0)) {
|
|
146
|
+
return new LatchwayError(
|
|
147
|
+
"protocol_response_invalid",
|
|
148
|
+
"Latchway returned invalid native error metadata.",
|
|
149
|
+
{ requestID, status },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return new LatchwayError(mapped, safeMessage(firstString(record.message, userInfo.message)), {
|
|
153
|
+
requestID,
|
|
154
|
+
status,
|
|
155
|
+
retryable,
|
|
156
|
+
operationID,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function nativeUnavailable(cause: unknown): LatchwayError {
|
|
161
|
+
return new LatchwayError(
|
|
162
|
+
"client_configuration_invalid",
|
|
163
|
+
"The Latchway native module is unavailable. Rebuild the application after installing the SDK.",
|
|
164
|
+
{ cause },
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function abortError(): Error {
|
|
169
|
+
if (typeof DOMException === "function") return new DOMException("The operation was aborted.", "AbortError");
|
|
170
|
+
const error = new Error("The operation was aborted.");
|
|
171
|
+
error.name = "AbortError";
|
|
172
|
+
return error;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isAbortError(value: unknown): boolean {
|
|
176
|
+
if (!isRecord(value)) return false;
|
|
177
|
+
const code = firstString(value.code, value.name);
|
|
178
|
+
return code === "cancelled" || code === "E_ABORTED" || code === "AbortError";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function safeMessage(value: string | undefined): string {
|
|
182
|
+
if (value === undefined || value.length === 0) return "The Latchway native operation failed.";
|
|
183
|
+
const bounded = value.replace(/\p{Cc}/gu, " ").slice(0, 512);
|
|
184
|
+
if (/eyJ|lwa_|lws_|refresh.?token|identity.?token|integrity.?token|[A-Za-z0-9_-]{64,}/iu.test(bounded)) {
|
|
185
|
+
return "Sensitive native error detail was redacted.";
|
|
186
|
+
}
|
|
187
|
+
return bounded;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function safeStatus(value: unknown): number | undefined {
|
|
191
|
+
return Number.isInteger(value) && (value as number) >= 100 && (value as number) <= 599
|
|
192
|
+
? value as number
|
|
193
|
+
: undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function firstString(...values: unknown[]): string | undefined {
|
|
197
|
+
return values.find((value): value is string => typeof value === "string");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function presentValues(
|
|
201
|
+
record: Record<string, unknown>,
|
|
202
|
+
userInfo: Record<string, unknown>,
|
|
203
|
+
keys: readonly string[],
|
|
204
|
+
): unknown[] {
|
|
205
|
+
const values: unknown[] = [];
|
|
206
|
+
for (const source of [record, userInfo]) {
|
|
207
|
+
for (const key of keys) {
|
|
208
|
+
const candidate = source[key];
|
|
209
|
+
if (Object.hasOwn(source, key) && candidate !== undefined && candidate !== null) values.push(candidate);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return values;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isCanonicalOperationID(value: unknown): value is string {
|
|
216
|
+
return typeof value === "string" && /^arq_[0-7][0-9A-HJKMNPQRSTVWXYZ]{25}$/u.test(value);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isNativeErrorCode(value: string): boolean {
|
|
220
|
+
return /^[a-z][a-z0-9_]{0,62}$/u.test(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
224
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
225
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DefaultLatchwayClient } from "./client.js";
|
|
2
|
+
import { DefaultLatchwayComponentClient } from "./component-client.js";
|
|
3
|
+
import { configure, configureComponent } from "./config.js";
|
|
4
|
+
import type {
|
|
5
|
+
LatchwayClient,
|
|
6
|
+
LatchwayComponentClient,
|
|
7
|
+
LatchwayComponentOptions,
|
|
8
|
+
LatchwayOptions,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
export { errorFromResponse, LatchwayError } from "@latchway/client";
|
|
12
|
+
export {
|
|
13
|
+
CONTRACT_VERSION,
|
|
14
|
+
PROTOCOL_VERSION,
|
|
15
|
+
SDK_KIND,
|
|
16
|
+
SDK_VERSION,
|
|
17
|
+
SUPPORTED_PROTOCOL_VERSIONS,
|
|
18
|
+
} from "./version.js";
|
|
19
|
+
export type {
|
|
20
|
+
AndroidKeyPolicy,
|
|
21
|
+
AndroidSecurityOptions,
|
|
22
|
+
AppleSecurityOptions,
|
|
23
|
+
AppleSoftwareKeyFallbackPolicy,
|
|
24
|
+
FetchImplementation,
|
|
25
|
+
GetIdentityToken,
|
|
26
|
+
IdentityTokenProvider,
|
|
27
|
+
LatchwayClient,
|
|
28
|
+
LatchwayComponentClient,
|
|
29
|
+
LatchwayComponentOptions,
|
|
30
|
+
LatchwayFetchInit,
|
|
31
|
+
LatchwayFetch,
|
|
32
|
+
LatchwayOptions,
|
|
33
|
+
QuotaLimit,
|
|
34
|
+
QuotaSnapshot,
|
|
35
|
+
ReactNativeComponentDiagnostics,
|
|
36
|
+
ReactNativeComponentAppleOptions,
|
|
37
|
+
ReactNativeComponentTrustSource,
|
|
38
|
+
ReactNativeDiagnostics,
|
|
39
|
+
ReactNativeIOSComponent,
|
|
40
|
+
ReactNativeIOSComponentKind,
|
|
41
|
+
ReactNativeDirectAttestationComponent,
|
|
42
|
+
ReactNativeDirectAttestationComponentKind,
|
|
43
|
+
ReactNativePlatform,
|
|
44
|
+
} from "./types.js";
|
|
45
|
+
|
|
46
|
+
export function createLatchwayClient(options: LatchwayOptions): LatchwayClient {
|
|
47
|
+
return new DefaultLatchwayClient(configure(options));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Creates a client only for JavaScript executing inside the signed iOS extension bundle. */
|
|
51
|
+
export function createLatchwayComponentClient(options: LatchwayComponentOptions): LatchwayComponentClient {
|
|
52
|
+
return new DefaultLatchwayComponentClient(configureComponent(options));
|
|
53
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { TurboModule } from "react-native";
|
|
2
|
+
import { TurboModuleRegistry } from "react-native";
|
|
3
|
+
|
|
4
|
+
export interface Spec extends TurboModule {
|
|
5
|
+
configure(clientID: string, configurationJSON: string): Promise<string>;
|
|
6
|
+
configureComponent(
|
|
7
|
+
clientID: string,
|
|
8
|
+
configurationJSON: string,
|
|
9
|
+
componentJSON: string,
|
|
10
|
+
): Promise<string>;
|
|
11
|
+
startRequest(
|
|
12
|
+
clientID: string,
|
|
13
|
+
operationID: string,
|
|
14
|
+
identityToken: string,
|
|
15
|
+
requestJSON: string,
|
|
16
|
+
): Promise<string>;
|
|
17
|
+
readResponseChunk(
|
|
18
|
+
clientID: string,
|
|
19
|
+
operationID: string,
|
|
20
|
+
responseID: string,
|
|
21
|
+
maximumBytes: number,
|
|
22
|
+
): Promise<string>;
|
|
23
|
+
closeResponse(clientID: string, responseID: string): Promise<void>;
|
|
24
|
+
refresh(clientID: string, operationID: string, identityToken: string): Promise<void>;
|
|
25
|
+
quota(
|
|
26
|
+
clientID: string,
|
|
27
|
+
operationID: string,
|
|
28
|
+
identityToken: string,
|
|
29
|
+
feature: string,
|
|
30
|
+
): Promise<string>;
|
|
31
|
+
diagnostics(clientID: string, operationID: string, identityToken: string): Promise<string>;
|
|
32
|
+
establishDirectAttestation(
|
|
33
|
+
clientID: string,
|
|
34
|
+
operationID: string,
|
|
35
|
+
): Promise<void>;
|
|
36
|
+
componentDiagnostics(
|
|
37
|
+
clientID: string,
|
|
38
|
+
operationID: string,
|
|
39
|
+
): Promise<string>;
|
|
40
|
+
prepareComponents(
|
|
41
|
+
clientID: string,
|
|
42
|
+
operationID: string,
|
|
43
|
+
identityToken: string,
|
|
44
|
+
componentsJSON: string,
|
|
45
|
+
): Promise<string>;
|
|
46
|
+
replaceComponent(
|
|
47
|
+
clientID: string,
|
|
48
|
+
operationID: string,
|
|
49
|
+
identityToken: string,
|
|
50
|
+
componentJSON: string,
|
|
51
|
+
): Promise<string>;
|
|
52
|
+
rootComponentDiagnostics(
|
|
53
|
+
clientID: string,
|
|
54
|
+
operationID: string,
|
|
55
|
+
componentJSON: string,
|
|
56
|
+
): Promise<string>;
|
|
57
|
+
revokeComponent(
|
|
58
|
+
clientID: string,
|
|
59
|
+
operationID: string,
|
|
60
|
+
identityToken: string,
|
|
61
|
+
componentJSON: string,
|
|
62
|
+
): Promise<void>;
|
|
63
|
+
revoke(clientID: string, operationID: string, identityToken: string): Promise<void>;
|
|
64
|
+
revokeFamily(clientID: string, operationID: string, identityToken: string): Promise<void>;
|
|
65
|
+
revokeFamilyWithComponents(
|
|
66
|
+
clientID: string,
|
|
67
|
+
operationID: string,
|
|
68
|
+
identityToken: string,
|
|
69
|
+
componentsJSON: string,
|
|
70
|
+
): Promise<void>;
|
|
71
|
+
cancel(clientID: string, operationID: string): void;
|
|
72
|
+
dispose(clientID: string): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default TurboModuleRegistry.getEnforcing<Spec>("NativeLatchway");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Spec } from "./NativeLatchway.js";
|
|
2
|
+
|
|
3
|
+
export type NativeLatchwayModule = Spec;
|
|
4
|
+
|
|
5
|
+
let testingModule: NativeLatchwayModule | undefined;
|
|
6
|
+
|
|
7
|
+
export async function nativeModule(): Promise<NativeLatchwayModule> {
|
|
8
|
+
if (testingModule !== undefined) return testingModule;
|
|
9
|
+
try {
|
|
10
|
+
return (await import("./NativeLatchway.js")).default;
|
|
11
|
+
} catch (cause) {
|
|
12
|
+
const error = new Error(
|
|
13
|
+
"The Latchway TurboModule is unavailable. Rebuild the native application after installing @latchway/react-native.",
|
|
14
|
+
{ cause },
|
|
15
|
+
);
|
|
16
|
+
error.name = "LatchwayNativeModuleUnavailable";
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setTestingNativeModule(module: NativeLatchwayModule | undefined): void {
|
|
22
|
+
testingModule = module;
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { LatchwayError } from "@latchway/client";
|
|
2
|
+
|
|
3
|
+
const forbiddenCredentialKeyFragments = [
|
|
4
|
+
"authorization",
|
|
5
|
+
"dpop",
|
|
6
|
+
"accesstoken",
|
|
7
|
+
"refreshtoken",
|
|
8
|
+
"privatekey",
|
|
9
|
+
"sessiontoken",
|
|
10
|
+
"identitytoken",
|
|
11
|
+
"integritytoken",
|
|
12
|
+
"attestationevidence",
|
|
13
|
+
"clientdatahash",
|
|
14
|
+
"requesthash",
|
|
15
|
+
"credential",
|
|
16
|
+
"secret",
|
|
17
|
+
"jwk",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
const forbiddenExactCredentialKeys = new Set(["key", "proof", "token"]);
|
|
21
|
+
|
|
22
|
+
/** Fails closed when a native return or rejection envelope names credential material. */
|
|
23
|
+
export function assertNoCredentialFields(value: unknown): void {
|
|
24
|
+
if (containsCredentialField(value)) {
|
|
25
|
+
throw new LatchwayError("protocol_response_invalid", "Latchway native output crossed the credential boundary.");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function containsCredentialField(value: unknown): boolean {
|
|
30
|
+
if (Array.isArray(value)) return value.some(containsCredentialField);
|
|
31
|
+
if (!isRecord(value)) return false;
|
|
32
|
+
for (const [key, item] of Object.entries(value)) {
|
|
33
|
+
const compact = key.replace(/[^A-Za-z0-9]/gu, "").toLowerCase();
|
|
34
|
+
if (forbiddenExactCredentialKeys.has(compact) ||
|
|
35
|
+
forbiddenCredentialKeyFragments.some((fragment) => compact.includes(fragment))) return true;
|
|
36
|
+
if (containsCredentialField(item)) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createLatchwayClient } from "./index.js";
|
|
2
|
+
import { setTestingNativeModule, type NativeLatchwayModule } from "./native/bridge.js";
|
|
3
|
+
|
|
4
|
+
/** Installs an in-process bridge for Node conformance tests. Never use this in an application build. */
|
|
5
|
+
export function installNativeModuleForTesting(module: NativeLatchwayModule): () => void {
|
|
6
|
+
setTestingNativeModule(module);
|
|
7
|
+
return () => { setTestingNativeModule(undefined); };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { createLatchwayClient };
|
|
11
|
+
export type { NativeLatchwayModule };
|