@latchway/react-native 0.0.0-bootstrap.0 → 1.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/LatchwayReactNative.podspec +33 -0
  3. package/NOTICE +7 -0
  4. package/README.md +293 -3
  5. package/SECURITY.md +69 -0
  6. package/android/build.gradle.kts +62 -0
  7. package/android/consumer-rules.pro +2 -0
  8. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  9. package/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  10. package/android/gradle.properties +4 -0
  11. package/android/gradlew +251 -0
  12. package/android/gradlew.bat +94 -0
  13. package/android/settings.gradle.kts +33 -0
  14. package/android/src/main/AndroidManifest.xml +3 -0
  15. package/android/src/main/java/dev/latchway/reactnative/LatchwayReactNativePackage.kt +25 -0
  16. package/android/src/main/java/dev/latchway/reactnative/NativeLatchwayModule.kt +964 -0
  17. package/android/src/test/java/dev/latchway/reactnative/NativeLatchwayModuleTest.kt +521 -0
  18. package/babel.cjs +22 -0
  19. package/babel.d.cts +7 -0
  20. package/contract.lock +7 -0
  21. package/docs/architecture.md +145 -0
  22. package/docs/conformance.md +68 -0
  23. package/docs/langchain.md +135 -0
  24. package/docs/native-installation.md +172 -0
  25. package/docs/physical-device-evidence.md +559 -0
  26. package/docs/releasing.md +360 -0
  27. package/docs/security.md +55 -0
  28. package/ios/LatchwayNativeBridge.swift +1551 -0
  29. package/ios/RCTNativeLatchway.h +9 -0
  30. package/ios/RCTNativeLatchway.mm +175 -0
  31. package/lib/client.d.ts +29 -0
  32. package/lib/client.d.ts.map +1 -0
  33. package/lib/client.js +939 -0
  34. package/lib/client.js.map +1 -0
  35. package/lib/component-client.d.ts +15 -0
  36. package/lib/component-client.d.ts.map +1 -0
  37. package/lib/component-client.js +141 -0
  38. package/lib/component-client.js.map +1 -0
  39. package/lib/config.d.ts +25 -0
  40. package/lib/config.d.ts.map +1 -0
  41. package/lib/config.js +261 -0
  42. package/lib/config.js.map +1 -0
  43. package/lib/coordinator.d.ts +19 -0
  44. package/lib/coordinator.d.ts.map +1 -0
  45. package/lib/coordinator.js +167 -0
  46. package/lib/coordinator.js.map +1 -0
  47. package/lib/errors.d.ts +5 -0
  48. package/lib/errors.d.ts.map +1 -0
  49. package/lib/errors.js +201 -0
  50. package/lib/errors.js.map +1 -0
  51. package/lib/index.d.ts +8 -0
  52. package/lib/index.d.ts.map +1 -0
  53. package/lib/index.js +13 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/native/NativeLatchway.d.ts +25 -0
  56. package/lib/native/NativeLatchway.d.ts.map +1 -0
  57. package/lib/native/NativeLatchway.js +3 -0
  58. package/lib/native/NativeLatchway.js.map +1 -0
  59. package/lib/native/bridge.d.ts +5 -0
  60. package/lib/native/bridge.d.ts.map +1 -0
  61. package/lib/native/bridge.js +17 -0
  62. package/lib/native/bridge.js.map +1 -0
  63. package/lib/native-output.d.ts +3 -0
  64. package/lib/native-output.d.ts.map +1 -0
  65. package/lib/native-output.js +43 -0
  66. package/lib/native-output.js.map +1 -0
  67. package/lib/polyfills.d.ts +8 -0
  68. package/lib/polyfills.d.ts.map +1 -0
  69. package/lib/polyfills.js +56 -0
  70. package/lib/polyfills.js.map +1 -0
  71. package/lib/request-id.d.ts +2 -0
  72. package/lib/request-id.d.ts.map +1 -0
  73. package/lib/request-id.js +5 -0
  74. package/lib/request-id.js.map +1 -0
  75. package/lib/runtime-symbols.d.ts +2 -0
  76. package/lib/runtime-symbols.d.ts.map +1 -0
  77. package/lib/runtime-symbols.js +9 -0
  78. package/lib/runtime-symbols.js.map +1 -0
  79. package/lib/testing.d.ts +7 -0
  80. package/lib/testing.d.ts.map +1 -0
  81. package/lib/testing.js +9 -0
  82. package/lib/testing.js.map +1 -0
  83. package/lib/types.d.ts +200 -0
  84. package/lib/types.d.ts.map +1 -0
  85. package/lib/types.js +2 -0
  86. package/lib/types.js.map +1 -0
  87. package/lib/version.d.ts +8 -0
  88. package/lib/version.d.ts.map +1 -0
  89. package/lib/version.js +8 -0
  90. package/lib/version.js.map +1 -0
  91. package/package.json +152 -6
  92. package/react-native.config.cjs +7 -0
  93. package/release-compatibility.json +64 -0
  94. package/src/client.ts +1022 -0
  95. package/src/component-client.ts +158 -0
  96. package/src/config.ts +368 -0
  97. package/src/coordinator.ts +195 -0
  98. package/src/errors.ts +225 -0
  99. package/src/index.ts +53 -0
  100. package/src/native/NativeLatchway.ts +75 -0
  101. package/src/native/bridge.ts +23 -0
  102. package/src/native-output.ts +43 -0
  103. package/src/polyfills.ts +50 -0
  104. package/src/request-id.ts +5 -0
  105. package/src/runtime-symbols.ts +9 -0
  106. package/src/testing.ts +11 -0
  107. package/src/types.ts +241 -0
  108. package/src/version.ts +7 -0
@@ -0,0 +1,158 @@
1
+ import { LatchwayError } from "@latchway/client";
2
+ import type { RuntimeComponentConfiguration } from "./config.js";
3
+ import { acquireComponent, type NativeLease } from "./coordinator.js";
4
+ import { fromNativeError } from "./errors.js";
5
+ import { assertNoCredentialFields } from "./native-output.js";
6
+ import type {
7
+ LatchwayComponentClient,
8
+ ReactNativeComponentDiagnostics,
9
+ ReactNativeComponentTrustSource,
10
+ ReactNativeDirectAttestationComponent,
11
+ ReactNativeIOSComponent,
12
+ } from "./types.js";
13
+
14
+ const componentTrustSources = new Set<ReactNativeComponentTrustSource>([
15
+ "direct_attested",
16
+ "delegated_from_attested_root",
17
+ "delegated_identity_only",
18
+ "delegated_direct_attested",
19
+ "identity_only",
20
+ "web_risk_verified",
21
+ "debug",
22
+ ]);
23
+
24
+ let nextComponentOperationID = 1;
25
+
26
+ export class DefaultLatchwayComponentClient implements LatchwayComponentClient {
27
+ readonly ready: Promise<void>;
28
+ private readonly lease: Promise<NativeLease>;
29
+ private disposed = false;
30
+
31
+ constructor(private readonly config: RuntimeComponentConfiguration) {
32
+ this.lease = acquireComponent(config);
33
+ this.ready = this.lease.then(async (lease) => { await lease.ready; });
34
+ }
35
+
36
+ async establishDirectAttestation(): Promise<void> {
37
+ this.assertActive();
38
+ const lease = await this.lease;
39
+ await lease.ready;
40
+ const operationID = makeComponentOperationID();
41
+ try {
42
+ await lease.module.establishDirectAttestation(lease.clientID, operationID);
43
+ } catch (cause) {
44
+ throw fromNativeError(cause);
45
+ }
46
+ }
47
+
48
+ async diagnostics(): Promise<ReactNativeComponentDiagnostics> {
49
+ this.assertActive();
50
+ const lease = await this.lease;
51
+ await lease.ready;
52
+ const operationID = makeComponentOperationID();
53
+ let encoded: string;
54
+ try {
55
+ encoded = await lease.module.componentDiagnostics(lease.clientID, operationID);
56
+ } catch (cause) {
57
+ throw fromNativeError(cause);
58
+ }
59
+ return parseComponentDiagnostics(encoded, this.config.component);
60
+ }
61
+
62
+ async dispose(): Promise<void> {
63
+ if (this.disposed) return;
64
+ this.disposed = true;
65
+ const lease = await this.lease;
66
+ await lease.release();
67
+ }
68
+
69
+ private assertActive(): void {
70
+ if (this.disposed) {
71
+ throw new LatchwayError("client_configuration_invalid", "This Latchway component client has been disposed.");
72
+ }
73
+ }
74
+ }
75
+
76
+ export function parseComponentDiagnostics(
77
+ encoded: string,
78
+ component: ReactNativeDirectAttestationComponent | ReactNativeIOSComponent,
79
+ ): ReactNativeComponentDiagnostics {
80
+ const value = parseRecord(encoded);
81
+ assertNoCredentialFields(value);
82
+ if (!hasOnlyKeys(value, [
83
+ "familyID", "componentID", "definitionID", "keychainAccessGroup", "keyAvailable", "keyStorage",
84
+ "grantAvailable", "sessionAvailable", "trustSource", "trustExpiresAt", "containingAppActionRequired",
85
+ ]) || value.definitionID !== component.definitionID ||
86
+ value.keychainAccessGroup !== component.keychainAccessGroup ||
87
+ typeof value.keyAvailable !== "boolean" || typeof value.keyStorage !== "string" ||
88
+ value.keyStorage.length === 0 || value.keyStorage.length > 128 || /\p{Cc}/u.test(value.keyStorage) ||
89
+ typeof value.grantAvailable !== "boolean" || typeof value.sessionAvailable !== "boolean" ||
90
+ typeof value.containingAppActionRequired !== "boolean") {
91
+ throw invalidDiagnostics();
92
+ }
93
+ const familyID = optionalID(value.familyID, /^fam_[A-Za-z0-9_-]{16,128}$/u);
94
+ const componentID = optionalID(value.componentID, /^cmp_[A-Za-z0-9_-]{16,128}$/u);
95
+ const trustExpiresAt = optionalString(value.trustExpiresAt);
96
+ if (trustExpiresAt !== undefined && !validRFC3339(trustExpiresAt)) throw invalidDiagnostics();
97
+ const trustSource = optionalString(value.trustSource);
98
+ if (trustSource !== undefined && !componentTrustSources.has(trustSource as ReactNativeComponentTrustSource)) {
99
+ throw invalidDiagnostics();
100
+ }
101
+ return {
102
+ ...(familyID === undefined ? {} : { familyID }),
103
+ ...(componentID === undefined ? {} : { componentID }),
104
+ definitionID: component.definitionID,
105
+ keychainAccessGroup: component.keychainAccessGroup,
106
+ keyAvailable: value.keyAvailable,
107
+ keyStorage: value.keyStorage,
108
+ grantAvailable: value.grantAvailable,
109
+ sessionAvailable: value.sessionAvailable,
110
+ ...(trustSource === undefined ? {} : { trustSource: trustSource as ReactNativeComponentTrustSource }),
111
+ ...(trustExpiresAt === undefined ? {} : { trustExpiresAt }),
112
+ containingAppActionRequired: value.containingAppActionRequired,
113
+ };
114
+ }
115
+
116
+ function parseRecord(encoded: string): Record<string, unknown> {
117
+ try {
118
+ const value: unknown = JSON.parse(encoded);
119
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
120
+ return value as Record<string, unknown>;
121
+ }
122
+ } catch {
123
+ // Never reflect native output into the public error.
124
+ }
125
+ throw invalidDiagnostics();
126
+ }
127
+
128
+ function optionalID(value: unknown, pattern: RegExp): string | undefined {
129
+ const result = optionalString(value);
130
+ if (result !== undefined && !pattern.test(result)) throw invalidDiagnostics();
131
+ return result;
132
+ }
133
+
134
+ function optionalString(value: unknown): string | undefined {
135
+ if (value === undefined || value === null) return undefined;
136
+ if (typeof value !== "string" || value.length === 0 || value.length > 512 || /\p{Cc}/u.test(value)) {
137
+ throw invalidDiagnostics();
138
+ }
139
+ return value;
140
+ }
141
+
142
+ function validRFC3339(value: string): boolean {
143
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) &&
144
+ !Number.isNaN(Date.parse(value));
145
+ }
146
+
147
+ function hasOnlyKeys(value: Record<string, unknown>, names: readonly string[]): boolean {
148
+ const expected = new Set(names);
149
+ return Object.keys(value).every((name) => expected.has(name));
150
+ }
151
+
152
+ function invalidDiagnostics(): LatchwayError {
153
+ return new LatchwayError("protocol_response_invalid", "Latchway returned invalid component diagnostics.");
154
+ }
155
+
156
+ function makeComponentOperationID(): string {
157
+ return `component-op-${nextComponentOperationID++}`;
158
+ }
package/src/config.ts ADDED
@@ -0,0 +1,368 @@
1
+ import { LatchwayError } from "@latchway/client";
2
+ import type {
3
+ LatchwayComponentOptions,
4
+ LatchwayOptions,
5
+ ReactNativeDirectAttestationComponent,
6
+ ReactNativeIOSComponent,
7
+ } from "./types.js";
8
+ import {
9
+ CONTRACT_VERSION,
10
+ PROTOCOL_VERSION,
11
+ REACT_NATIVE_FRAMEWORK_ID,
12
+ REACT_NATIVE_FRAMEWORK_VERSION,
13
+ SDK_VERSION,
14
+ } from "./version.js";
15
+
16
+ export interface RuntimeConfiguration {
17
+ baseURL: URL;
18
+ applicationID: string;
19
+ environment: string;
20
+ identityProvider: string;
21
+ appVersion: string;
22
+ getIdentityToken: () => Promise<string>;
23
+ appleSharedKeychainAccessGroups: readonly string[];
24
+ nativeJSON: string;
25
+ fingerprint: string;
26
+ scope: string;
27
+ }
28
+
29
+ export interface RuntimeComponentConfiguration {
30
+ nativeJSON: string;
31
+ componentJSON: string;
32
+ fingerprint: string;
33
+ scope: string;
34
+ component: ReactNativeDirectAttestationComponent;
35
+ }
36
+
37
+ export function configure(options: LatchwayOptions): RuntimeConfiguration {
38
+ const baseURL = parseBaseURL(options.baseURL, options.allowInsecureLoopback === true);
39
+ const applicationID = applicationResourceID(options.applicationID);
40
+ const environment = identifier(options.environment, "environment");
41
+ const identityProvider = identifier(options.identityProvider ?? "custom_jwt", "identityProvider");
42
+ const appVersion = boundedString(options.appVersion ?? SDK_VERSION, "appVersion", 128);
43
+ const getIdentityToken = tokenProvider(options);
44
+ const rootKeychainAccessGroup = options.apple?.rootKeychainAccessGroup;
45
+ if (options.apple !== undefined && rootKeychainAccessGroup === undefined) {
46
+ throw new LatchwayError(
47
+ "client_configuration_invalid",
48
+ "apple.rootKeychainAccessGroup is required for an Apple configuration.",
49
+ );
50
+ }
51
+ const legacySharedKeychainAccessGroups = options.apple?.legacySharedKeychainAccessGroups ?? [];
52
+ if (rootKeychainAccessGroup !== undefined) {
53
+ validateRootKeychainAccessGroups(rootKeychainAccessGroup, legacySharedKeychainAccessGroups);
54
+ }
55
+ const storageNamespace = options.apple?.storageNamespace;
56
+ if (storageNamespace !== undefined) boundedString(storageNamespace, "apple.storageNamespace", 128);
57
+ const appleFallback = options.apple?.softwareKeyFallbackPolicy ?? "disallow";
58
+ if (appleFallback !== "disallow" && appleFallback !== "allow") {
59
+ throw new LatchwayError("client_configuration_invalid", "apple.softwareKeyFallbackPolicy is invalid.");
60
+ }
61
+ const androidKeyPolicy = options.android?.keyPolicy ?? "strongbox_preferred";
62
+ if (!new Set(["hardware_backed_required", "strongbox_preferred", "software_allowed"]).has(androidKeyPolicy)) {
63
+ throw new LatchwayError("client_configuration_invalid", "android.keyPolicy is invalid.");
64
+ }
65
+ const projectNumber = options.android?.playIntegrityCloudProjectNumber;
66
+ if (projectNumber !== undefined &&
67
+ (!/^[1-9][0-9]{5,18}$/u.test(projectNumber) || BigInt(projectNumber) > 9_223_372_036_854_775_807n)) {
68
+ throw new LatchwayError(
69
+ "client_configuration_invalid",
70
+ "android.playIntegrityCloudProjectNumber must be a decimal Google Cloud project number.",
71
+ );
72
+ }
73
+
74
+ const nativeConfiguration = {
75
+ baseURL: baseURL.href,
76
+ applicationID,
77
+ environment,
78
+ identityProvider,
79
+ appVersion,
80
+ sdkVersion: SDK_VERSION,
81
+ frameworkID: REACT_NATIVE_FRAMEWORK_ID,
82
+ frameworkVersion: REACT_NATIVE_FRAMEWORK_VERSION,
83
+ contractVersion: CONTRACT_VERSION,
84
+ protocolVersion: PROTOCOL_VERSION,
85
+ allowInsecureLoopback: options.allowInsecureLoopback === true,
86
+ apple: {
87
+ appAttestEnabled: options.apple?.appAttestEnabled ?? true,
88
+ softwareKeyFallbackPolicy: appleFallback,
89
+ ...(rootKeychainAccessGroup === undefined ? {} : {
90
+ rootKeychainAccessGroup,
91
+ legacySharedKeychainAccessGroups: Array.from(legacySharedKeychainAccessGroups),
92
+ }),
93
+ ...(storageNamespace === undefined ? {} : { storageNamespace }),
94
+ },
95
+ android: {
96
+ keyPolicy: androidKeyPolicy,
97
+ ...(projectNumber === undefined ? {} : { playIntegrityCloudProjectNumber: projectNumber }),
98
+ },
99
+ };
100
+ const nativeJSON = JSON.stringify(nativeConfiguration);
101
+ return {
102
+ baseURL,
103
+ applicationID,
104
+ environment,
105
+ identityProvider,
106
+ appVersion,
107
+ getIdentityToken,
108
+ appleSharedKeychainAccessGroups: Array.from(legacySharedKeychainAccessGroups),
109
+ nativeJSON,
110
+ fingerprint: nativeJSON,
111
+ scope: `${baseURL.origin}|${applicationID}|${environment}`,
112
+ };
113
+ }
114
+
115
+ export function encodeIOSComponentDescriptors(
116
+ values: readonly ReactNativeIOSComponent[],
117
+ sharedKeychainAccessGroups: readonly string[],
118
+ ): string {
119
+ if (!runtimeArray(values) || values.length === 0 || values.length > 256) {
120
+ throw new LatchwayError(
121
+ "client_configuration_invalid",
122
+ "At least one native iOS component descriptor is required.",
123
+ );
124
+ }
125
+ const components = values.map((value) => validateIOSComponent(value, sharedKeychainAccessGroups));
126
+ if (new Set(components.map((component) => component.definitionID)).size !== components.length) {
127
+ throw new LatchwayError(
128
+ "client_configuration_invalid",
129
+ "Native iOS component definition IDs must be unique in one operation.",
130
+ );
131
+ }
132
+ return boundedComponentJSON(components);
133
+ }
134
+
135
+ export function encodeIOSComponentDescriptor(
136
+ value: ReactNativeIOSComponent,
137
+ sharedKeychainAccessGroups: readonly string[],
138
+ ): string {
139
+ return boundedComponentJSON(validateIOSComponent(value, sharedKeychainAccessGroups));
140
+ }
141
+
142
+ function boundedComponentJSON(value: ReactNativeIOSComponent | readonly ReactNativeIOSComponent[]): string {
143
+ const encoded = JSON.stringify(value);
144
+ // Every validated descriptor field is ASCII, so UTF-16 length is the exact
145
+ // UTF-8 byte length accepted by the native bridge.
146
+ if (encoded.length > 65_536) {
147
+ throw new LatchwayError(
148
+ "client_configuration_invalid",
149
+ "Native iOS component descriptors exceed the 64 KiB bridge limit.",
150
+ );
151
+ }
152
+ return encoded;
153
+ }
154
+
155
+ export function configureComponent(options: LatchwayComponentOptions): RuntimeComponentConfiguration {
156
+ const baseURL = parseBaseURL(options.baseURL, options.allowInsecureLoopback === true);
157
+ const applicationID = applicationResourceID(options.applicationID);
158
+ const environment = identifier(options.environment, "environment");
159
+ const appVersion = boundedString(options.appVersion ?? SDK_VERSION, "appVersion", 128);
160
+ const component = validateDirectAttestationComponent(options.component);
161
+ if (options.apple === undefined) {
162
+ throw new LatchwayError(
163
+ "client_configuration_invalid",
164
+ "apple root Keychain configuration is required for an iOS component.",
165
+ );
166
+ }
167
+ validateRootKeychainAccessGroups(
168
+ options.apple.rootKeychainAccessGroup,
169
+ options.apple.legacySharedKeychainAccessGroups,
170
+ );
171
+ if (!options.apple.legacySharedKeychainAccessGroups.includes(component.keychainAccessGroup)) {
172
+ throw new LatchwayError(
173
+ "client_configuration_invalid",
174
+ "The component Keychain group must be an explicit shared group of the containing root application.",
175
+ );
176
+ }
177
+ const storageNamespace = options.apple?.storageNamespace;
178
+ if (storageNamespace !== undefined) boundedString(storageNamespace, "apple.storageNamespace", 128);
179
+ const appleFallback = options.apple?.softwareKeyFallbackPolicy ?? "disallow";
180
+ if (appleFallback !== "disallow" && appleFallback !== "allow") {
181
+ throw new LatchwayError("client_configuration_invalid", "apple.softwareKeyFallbackPolicy is invalid.");
182
+ }
183
+ const nativeJSON = JSON.stringify({
184
+ baseURL: baseURL.href,
185
+ applicationID,
186
+ environment,
187
+ appVersion,
188
+ sdkVersion: SDK_VERSION,
189
+ contractVersion: CONTRACT_VERSION,
190
+ protocolVersion: PROTOCOL_VERSION,
191
+ allowInsecureLoopback: options.allowInsecureLoopback === true,
192
+ apple: {
193
+ rootKeychainAccessGroup: options.apple.rootKeychainAccessGroup,
194
+ legacySharedKeychainAccessGroups: Array.from(options.apple.legacySharedKeychainAccessGroups),
195
+ softwareKeyFallbackPolicy: appleFallback,
196
+ ...(storageNamespace === undefined ? {} : { storageNamespace }),
197
+ },
198
+ });
199
+ const componentJSON = JSON.stringify(component);
200
+ return {
201
+ nativeJSON,
202
+ componentJSON,
203
+ fingerprint: `${nativeJSON}|${componentJSON}`,
204
+ scope: `${baseURL.origin}|${applicationID}|${environment}|${component.definitionID}`,
205
+ component,
206
+ };
207
+ }
208
+
209
+ function applicationResourceID(value: string): string {
210
+ const applicationID = boundedString(value, "applicationID", 30);
211
+ if (!/^app_[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(applicationID)) {
212
+ throw new LatchwayError(
213
+ "client_configuration_invalid",
214
+ "applicationID must be the canonical app_ resource ID returned by the Admin API.",
215
+ );
216
+ }
217
+ return applicationID;
218
+ }
219
+
220
+ function tokenProvider(options: LatchwayOptions): () => Promise<string> {
221
+ if (options.getIdentityToken !== undefined && options.identityTokenProvider !== undefined) {
222
+ throw new LatchwayError(
223
+ "client_configuration_invalid",
224
+ "Configure either getIdentityToken or identityTokenProvider, not both.",
225
+ );
226
+ }
227
+ if (typeof options.getIdentityToken === "function") return options.getIdentityToken;
228
+ if (typeof options.identityTokenProvider?.getIdentityToken === "function") {
229
+ return options.identityTokenProvider.getIdentityToken.bind(options.identityTokenProvider);
230
+ }
231
+ throw new LatchwayError("client_configuration_invalid", "getIdentityToken is required.");
232
+ }
233
+
234
+ function parseBaseURL(value: string, allowInsecureLoopback: boolean): URL {
235
+ let url: URL;
236
+ try {
237
+ url = new URL(value);
238
+ } catch (cause) {
239
+ throw new LatchwayError("client_configuration_invalid", "baseURL must be an absolute URL.", { cause });
240
+ }
241
+ if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.pathname !== "/") {
242
+ throw new LatchwayError(
243
+ "client_configuration_invalid",
244
+ "baseURL must identify an origin without credentials, path, query, or fragment.",
245
+ );
246
+ }
247
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
248
+ if (url.protocol !== "https:" && !(allowInsecureLoopback && loopback && url.protocol === "http:")) {
249
+ throw new LatchwayError(
250
+ "client_configuration_invalid",
251
+ "baseURL must use HTTPS; HTTP is limited to explicitly enabled loopback conformance.",
252
+ );
253
+ }
254
+ return new URL(url.origin);
255
+ }
256
+
257
+ function identifier(value: string, field: string): string {
258
+ if (!/^[a-z][a-z0-9_-]{0,62}$/u.test(value)) {
259
+ throw new LatchwayError("client_configuration_invalid", `${field} must be a lowercase Latchway identifier.`);
260
+ }
261
+ return value;
262
+ }
263
+
264
+ function boundedString(value: string, field: string, maximum: number): string {
265
+ if (typeof value !== "string" || value.length === 0 || value.length > maximum || /\p{Cc}/u.test(value)) {
266
+ throw new LatchwayError(
267
+ "client_configuration_invalid",
268
+ `${field} must contain between 1 and ${maximum} printable characters.`,
269
+ );
270
+ }
271
+ return value;
272
+ }
273
+
274
+ function validateRootKeychainAccessGroups(root: string, legacy: readonly string[]): void {
275
+ if (!concreteKeychainAccessGroup(root)) {
276
+ throw new LatchwayError(
277
+ "client_configuration_invalid",
278
+ "apple.rootKeychainAccessGroup must be a fully resolved concrete dotted access group.",
279
+ );
280
+ }
281
+ if (!Array.isArray(legacy) || !legacy.every(concreteKeychainAccessGroup) ||
282
+ legacy.includes(root) || new Set(legacy).size !== legacy.length) {
283
+ throw new LatchwayError(
284
+ "client_configuration_invalid",
285
+ "apple.legacySharedKeychainAccessGroups must contain distinct concrete groups other than the root group.",
286
+ );
287
+ }
288
+ }
289
+
290
+ function concreteKeychainAccessGroup(value: unknown): value is string {
291
+ return typeof value === "string" && value.length >= 3 && value.length <= 255 &&
292
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/u.test(value);
293
+ }
294
+
295
+ function validateDirectAttestationComponent(value: ReactNativeDirectAttestationComponent): ReactNativeDirectAttestationComponent {
296
+ if (!isRecord(value) ||
297
+ !hasOnlyKeys(value, ["definitionID", "kind", "keychainAccessGroup", "requestedFeatures"]) ||
298
+ typeof value.definitionID !== "string" || !validIdentifier(value.definitionID) ||
299
+ (value.kind !== "action_extension" && value.kind !== "sso_extension") ||
300
+ !concreteKeychainAccessGroup(value.keychainAccessGroup) ||
301
+ !Array.isArray(value.requestedFeatures) || value.requestedFeatures.length === 0 ||
302
+ value.requestedFeatures.length > 256 ||
303
+ !value.requestedFeatures.every((feature) => typeof feature === "string" && validIdentifier(feature)) ||
304
+ new Set(value.requestedFeatures).size !== value.requestedFeatures.length) {
305
+ throw new LatchwayError(
306
+ "client_configuration_invalid",
307
+ "The direct-attestation component descriptor is invalid.",
308
+ );
309
+ }
310
+ return {
311
+ definitionID: value.definitionID,
312
+ kind: value.kind,
313
+ keychainAccessGroup: value.keychainAccessGroup,
314
+ requestedFeatures: Array.from(value.requestedFeatures as string[]),
315
+ };
316
+ }
317
+
318
+ function validateIOSComponent(
319
+ value: ReactNativeIOSComponent,
320
+ sharedKeychainAccessGroups: readonly string[],
321
+ ): ReactNativeIOSComponent {
322
+ const kinds = new Set([
323
+ "widget",
324
+ "share_extension",
325
+ "app_intent_extension",
326
+ "notification_service_extension",
327
+ "action_extension",
328
+ "sso_extension",
329
+ ]);
330
+ if (!isRecord(value) ||
331
+ !hasOnlyKeys(value, ["definitionID", "kind", "keychainAccessGroup", "requestedFeatures"]) ||
332
+ typeof value.definitionID !== "string" || !validIdentifier(value.definitionID) ||
333
+ typeof value.kind !== "string" || !kinds.has(value.kind) ||
334
+ !concreteKeychainAccessGroup(value.keychainAccessGroup) ||
335
+ !sharedKeychainAccessGroups.includes(value.keychainAccessGroup) ||
336
+ !Array.isArray(value.requestedFeatures) || value.requestedFeatures.length === 0 ||
337
+ value.requestedFeatures.length > 256 ||
338
+ !value.requestedFeatures.every((feature) => typeof feature === "string" && validIdentifier(feature)) ||
339
+ new Set(value.requestedFeatures).size !== value.requestedFeatures.length) {
340
+ throw new LatchwayError(
341
+ "client_configuration_invalid",
342
+ "The native iOS component descriptor is invalid or its Keychain group is not explicitly shared.",
343
+ );
344
+ }
345
+ return {
346
+ definitionID: value.definitionID,
347
+ kind: value.kind,
348
+ keychainAccessGroup: value.keychainAccessGroup,
349
+ requestedFeatures: Array.from(value.requestedFeatures as string[]),
350
+ };
351
+ }
352
+
353
+ function validIdentifier(value: string): boolean {
354
+ return /^[a-z][a-z0-9_-]{0,62}$/u.test(value);
355
+ }
356
+
357
+ function runtimeArray(value: unknown): boolean {
358
+ return Array.isArray(value);
359
+ }
360
+
361
+ function hasOnlyKeys(value: Record<string, unknown>, names: readonly string[]): boolean {
362
+ const expected = new Set(names);
363
+ return Object.keys(value).every((name) => expected.has(name));
364
+ }
365
+
366
+ function isRecord(value: unknown): value is Record<string, unknown> {
367
+ return typeof value === "object" && value !== null && !Array.isArray(value);
368
+ }